diff --git a/README.md b/README.md index c2e6d9d..780c9d2 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,30 @@ not using an Opskit registry. It follows the manager's core readiness policy. Operational routes can expose metadata, revisions, checksums, redacted values, and error strings. Protect them with Servekit endpoint options appropriate for the deployment. +## Opskit reload command + +Configkit exposes reload as an Opskit command handler in the root package: + +```go +reload := configkit.ReloadCommand(manager, source, pipeline, + configkit.WithReloadCommandName("config/reload"), + configkit.WithReloadCommandDescription("reloads Configkit configuration from source"), +) + +ops.MustRegister(reload, opskit.Informational()) +``` + +The command descriptor defaults to `config/reload`. Completed reload failures +are returned as completed Opskit command results with failure metadata because +Configkit preserves last-known-good state. Context cancellation and deadline +failures are returned as failed command results with no result payload. + +The result payload is `configkit.ReloadCommandResult`. It does not include typed +config values or redacted inspection output. It may include attempt status, +manager state, revision, checksum, and error strings. Normal returned errors +are caller-owned operational output and should be safe for the command audience. +Recovered panic payloads are sanitized into safe stage-specific messages. + ## Workerkit reload command adapter Configkit core does not poll, watch files, schedule reloads, or expose HTTP reload routes. @@ -381,7 +405,9 @@ and deadline failures are returned as command errors. The payload does not include typed config values or redacted inspection output. It may include attempt status, manager state, revision, checksum, and error -strings, so those values should be safe for the command audience. +strings. Normal returned errors are caller-owned operational output and should +be safe for the command audience. Recovered panic payloads are sanitized into +safe stage-specific messages. ## Observability diff --git a/doc.go b/doc.go index 5510046..59cb02a 100644 --- a/doc.go +++ b/doc.go @@ -29,6 +29,7 @@ // packages that need current configuration state or safe lifecycle inspection // without mutating configuration lifecycle state. Manager also directly // implements Opskit's Component, ReadinessContributor, and Inspector contracts. +// ReloadCommand exposes reload as an Opskit CommandHandler and CommandDescriber. // Operational views do not expose the typed configuration value, but they can // include caller-provided metadata, revisions, redacted values, and error // strings. diff --git a/docs/api.md b/docs/api.md index af0403a..55c6b7c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -398,12 +398,16 @@ configuration into a framework. - `WithIdentity(name string)` - Sets the manager's Opskit component name. The default is `config`. + Sets the manager's Opskit component name. The default is `config`. Non-empty + names should satisfy Opskit component-name rules; invalid names may fail when + the manager is registered with an Opskit registry. - `WithComponentInfo(info opskit.ComponentInfo)` Sets the manager's Opskit component identity. Empty fields fall back to - Configkit defaults. + Configkit defaults. Non-empty component names should satisfy Opskit + component-name rules; invalid names may fail when the manager is registered + with an Opskit registry. - `WithDegradedReady(ready bool)` @@ -832,6 +836,60 @@ this repository's `go.mod`. degraded lifecycle state not ready. The default is `true` because degraded means the last-known-good snapshot remains active. +### Opskit reload command + +- `ReloadCommand[T](manager, source, pipeline, opts...)` + + Creates an Opskit component that implements `opskit.CommandHandler` and + `opskit.CommandDescriber` for Configkit reload. + + Default component name: `config-reload` + + Default command name: `config/reload` + + The command calls + `manager.LoadFromSource(ctx, configkit.AttemptKindReload, source, pipeline)`. + Completed reload failures are returned as completed Opskit command results + with failure metadata. Context cancellation and deadline failures are returned + as failed command results with no result payload. + +- `ReloadCommandResult` + + Safe operational result payload for reload commands. + + Fields: + + - `attempt_id` + - `attempt_status` + - `manager_state` + - `published` + - `changed` + - `current_checksum` + - `current_revision` + - `error` + + The payload does not expose typed config values or redacted inspection output. + Revisions, checksums, and normal returned error strings are still + operationally visible and should be safe for the command audience. Recovered + panic payloads are sanitized into safe stage-specific messages. + +- `ReloadCommandOption` + + Reload command configuration hook. + +- `WithReloadCommandName(name string)` + + Sets the Opskit command name. Empty names preserve the default. + +- `WithReloadCommandDescription(description string)` + + Sets the command discovery description. + +- `WithReloadCommandComponentInfo(info opskit.ComponentInfo)` + + Sets the Opskit component identity for the reload command handler. Empty + fields fall back to Configkit defaults. + ## Package `worker` The `worker` package adapts Configkit reloads into Workerkit commands. It is @@ -846,8 +904,8 @@ the Workerkit runtime itself in Opskit for readiness and generic inspection; - `ReloadCommand[T](manager, source, pipeline, opts...)` - Creates a Workerkit command spec that calls - `manager.LoadFromSource(ctx, configkit.AttemptKindReload, source, pipeline)`. + Creates a Workerkit command spec backed by the root Configkit Opskit reload + command. Default command name: `config/reload` @@ -863,8 +921,9 @@ the Workerkit runtime itself in Opskit for readiness and generic inspection; - `error` The payload does not expose typed config values or redacted inspection output. - Revisions, checksums, and error strings are still operationally visible and - should be safe for the command audience. + Revisions, checksums, and normal returned error strings are still + operationally visible and should be safe for the command audience. Recovered + panic payloads are sanitized into safe stage-specific messages. - `ReloadCommandOption` diff --git a/docs/composition.md b/docs/composition.md index 5a5c033..31c965e 100644 --- a/docs/composition.md +++ b/docs/composition.md @@ -122,9 +122,17 @@ server := servekit.New( ) ``` -## Reload Commands with Workerkit +## Reload Commands -Use `configkit/worker` when reload should be exposed as a Workerkit command: +Root Configkit exposes reload as an Opskit command handler: + +```go +reload := configkit.ReloadCommand(manager, source, pipeline) +ops.MustRegister(reload, opskit.Informational()) +``` + +Use `configkit/worker` when that reload command should be dispatched through a +Workerkit runtime today: ```go err := runtime.Register(workerkit.WorkerSpec{ @@ -137,7 +145,8 @@ err := runtime.Register(workerkit.WorkerSpec{ Workerkit v0.2.0 runtimes implement Opskit component, readiness, and inspection contracts directly. Register the runtime in the same Opskit registry as the -Configkit manager, then keep `configkit/worker` focused on the reload command. +Configkit manager and reload command handler, then keep `configkit/worker` +focused on Workerkit dispatch. Default command name: @@ -151,9 +160,9 @@ The command calls: manager.LoadFromSource(ctx, configkit.AttemptKindReload, source, pipeline) ``` -Failed reloads return a successful Workerkit command result containing failure -metadata. That preserves the command payload and lets Configkit report degraded -state without treating every failed reload as a Workerkit dispatch failure. +Failed reloads return a completed command result containing failure metadata. +That preserves the command payload and lets Configkit report degraded state +without treating every failed reload as a dispatch failure. ## Full Production Shape @@ -165,7 +174,8 @@ A typical composed service has: - Workerkit runtime for operational commands - `servekit.WithOps` for shared readiness and generic component inspection - optional `opshttp.Mount` for read-only Configkit-specific inspection -- `worker.ReloadCommand` for reload +- `configkit.ReloadCommand` for the Opskit reload command +- optional `worker.ReloadCommand` for Workerkit dispatch - app routes that read current config through `manager.Value()` This keeps the ownership clear: diff --git a/docs/operational-safety.md b/docs/operational-safety.md index 0ab5216..d9e1b34 100644 --- a/docs/operational-safety.md +++ b/docs/operational-safety.md @@ -77,9 +77,12 @@ Use `EmptyRedactor[T]()` until a field is explicitly safe to expose. ## Error Strings -Validation errors, source read errors, and recovered panic strings may be -recorded in attempts, status, logs, telemetry, ops HTTP responses, and reload -command payloads. +Validation errors and source read errors may be recorded in attempts, status, +logs, telemetry, ops HTTP responses, and reload command payloads. Normal +returned errors are caller-owned operational output. + +Recovered panic payloads are not exposed; Configkit records safe +stage-specific panic messages instead. Do not include raw secret values in errors. @@ -141,9 +144,10 @@ opshttp.Mount(server, manager, ) ``` -## Worker Reload Command +## Reload Command -`configkit/worker.ReloadCommand` returns operational reload metadata: +`configkit.ReloadCommand` and `configkit/worker.ReloadCommand` return +operational reload metadata: - attempt ID - attempt status @@ -155,8 +159,9 @@ opshttp.Mount(server, manager, - error string The payload does not include typed config values or redacted inspection output. -Revisions, checksums, and errors are still visible to whoever can dispatch or -inspect the command result. +Revisions, checksums, and normal returned error strings are still visible to +whoever can dispatch or inspect the command result. Recovered panic payloads are +sanitized into safe stage-specific messages. ## Future Operational Endpoints diff --git a/docs/reloads.md b/docs/reloads.md index 22d46d1..a1716eb 100644 --- a/docs/reloads.md +++ b/docs/reloads.md @@ -144,13 +144,15 @@ routes, or rebuild clients. Common trigger owners: - application startup code for initial load -- Workerkit commands for operator-triggered reload +- Opskit commands for operator-triggered reload +- Workerkit command dispatch for runtime execution - application-specific signal handlers - deployment hooks - custom source watchers outside Configkit core -Use `configkit/worker` when Workerkit should expose reload as an operational -command. +Use root `configkit.ReloadCommand` to expose reload through Opskit's command +contracts. Use `configkit/worker` when Workerkit should dispatch that reload as +an operational command. ## Examples diff --git a/manager.go b/manager.go index e9f07a5..62dc0d3 100644 --- a/manager.go +++ b/manager.go @@ -76,7 +76,9 @@ func WithAttemptHistoryLimit(limit int) ManagerOption { // WithIdentity sets the Opskit component name for the manager. // -// The default name is "config". Empty names are ignored. +// The default name is "config". Empty names are ignored. Non-empty names should +// satisfy Opskit component-name rules; invalid names may fail when the manager +// is registered with an Opskit registry. func WithIdentity(name string) ManagerOption { return func(options *managerOptions) { if name == "" { @@ -90,7 +92,9 @@ func WithIdentity(name string) ManagerOption { // WithComponentInfo sets the Opskit component identity for the manager. // // Empty fields fall back to Configkit defaults. Labels are appended to stable -// Configkit labels; the kit=configkit label is always preserved. +// Configkit labels; the kit=configkit label is always preserved. Non-empty +// component names should satisfy Opskit component-name rules; invalid names may +// fail when the manager is registered with an Opskit registry. func WithComponentInfo(info opskit.ComponentInfo) ManagerOption { return func(options *managerOptions) { options.componentInfo = info diff --git a/opshttp/opshttp.go b/opshttp/opshttp.go index 66aa070..a85fea2 100644 --- a/opshttp/opshttp.go +++ b/opshttp/opshttp.go @@ -131,7 +131,10 @@ type ReadinessProvider interface { // ReadinessCheck adapts Configkit readiness into a Servekit readiness check. // -// Readiness follows the provider's core Configkit readiness policy. +// For composed Kit Series services, prefer registering Manager with an Opskit +// registry and passing that registry to Servekit with servekit.WithOps. +// ReadinessCheck remains useful for standalone Servekit services that are not +// using Opskit. func ReadinessCheck(provider ReadinessProvider) servekit.ReadinessCheck { return func(ctx context.Context) error { if err := ctx.Err(); err != nil { diff --git a/reload_command.go b/reload_command.go new file mode 100644 index 0000000..993635e --- /dev/null +++ b/reload_command.go @@ -0,0 +1,259 @@ +package configkit + +import ( + "context" + "errors" + "time" + + opskit "github.com/jaredjakacky/opskit" +) + +const ( + defaultReloadCommandComponentName = "config-reload" + defaultReloadCommandComponentKind = "command_handler" + defaultReloadCommandComponentDescription = "Configkit reload command handler" + defaultReloadCommandName = "config/reload" + defaultReloadCommandDescription = "reloads Configkit configuration from source" +) + +var ( + _ opskit.Component = (*ReloadCommandHandler[any])(nil) + _ opskit.CommandHandler = (*ReloadCommandHandler[any])(nil) + _ opskit.CommandDescriber = (*ReloadCommandHandler[any])(nil) +) + +// ReloadCommandOption configures a Configkit Opskit reload command. +type ReloadCommandOption func(*reloadCommandOptions) + +type reloadCommandOptions struct { + componentInfo opskit.ComponentInfo + commandName string + description string +} + +// WithReloadCommandName sets the Opskit command name. +// +// The default is config/reload. Empty names preserve the default. +func WithReloadCommandName(name string) ReloadCommandOption { + return func(options *reloadCommandOptions) { + if name == "" { + return + } + options.commandName = name + } +} + +// WithReloadCommandDescription sets the command discovery description. +func WithReloadCommandDescription(description string) ReloadCommandOption { + return func(options *reloadCommandOptions) { + options.description = description + } +} + +// WithReloadCommandComponentInfo sets the Opskit component identity for the +// reload command handler. +// +// Empty fields fall back to Configkit defaults. Labels are appended to stable +// Configkit labels; the kit=configkit label is always preserved. +func WithReloadCommandComponentInfo(info opskit.ComponentInfo) ReloadCommandOption { + return func(options *reloadCommandOptions) { + options.componentInfo = reloadCommandComponentInfo(info) + } +} + +// ReloadCommandHandler exposes Configkit reload as an Opskit command component. +type ReloadCommandHandler[T any] struct { + manager *Manager[T] + source Source + pipeline Pipeline[T] + componentInfo opskit.ComponentInfo + descriptor opskit.CommandDescriptor +} + +// ReloadCommand creates an Opskit command handler that reloads Configkit state. +// +// The command calls Manager.LoadFromSource with AttemptKindReload. Completed +// reload failures are returned as completed Opskit command results with failure +// metadata in Result, because Configkit preserves the last-known-good snapshot. +// Context cancellation and deadline failures are returned as failed command +// results with no Result payload. +func ReloadCommand[T any](manager *Manager[T], source Source, pipeline Pipeline[T], opts ...ReloadCommandOption) *ReloadCommandHandler[T] { + options := defaultReloadCommandOptions() + for _, opt := range opts { + if opt != nil { + opt(&options) + } + } + + return &ReloadCommandHandler[T]{ + manager: manager, + source: source, + pipeline: pipeline, + componentInfo: cloneOpsComponentInfo(options.componentInfo), + descriptor: opskit.CommandDescriptor{ + Name: options.commandName, + Description: options.description, + Dangerous: true, + Idempotent: false, + Attributes: []opskit.Attribute{ + opskit.Attr("kit", "configkit"), + opskit.Attr("command", "reload"), + }, + }, + } +} + +func defaultReloadCommandOptions() reloadCommandOptions { + return reloadCommandOptions{ + componentInfo: reloadCommandComponentInfo(opskit.ComponentInfo{}), + commandName: defaultReloadCommandName, + description: defaultReloadCommandDescription, + } +} + +func reloadCommandComponentInfo(info opskit.ComponentInfo) opskit.ComponentInfo { + out := opskit.ComponentInfo{ + Name: defaultReloadCommandComponentName, + Kind: defaultReloadCommandComponentKind, + Description: defaultReloadCommandComponentDescription, + Labels: []opskit.Attribute{ + opskit.Attr("kit", "configkit"), + }, + } + if info.Name != "" { + out.Name = info.Name + } + if info.Kind != "" { + out.Kind = info.Kind + } + if info.Description != "" { + out.Description = info.Description + } + if len(info.Labels) > 0 { + out.Labels = mergeOpsComponentLabels(info.Labels) + } + return out +} + +// ComponentInfo returns the Opskit identity for this reload command handler. +func (h *ReloadCommandHandler[T]) ComponentInfo() opskit.ComponentInfo { + if h == nil { + return reloadCommandComponentInfo(opskit.ComponentInfo{}) + } + return cloneOpsComponentInfo(h.componentInfo) +} + +// Status returns whether this reload command handler is configured. +func (h *ReloadCommandHandler[T]) Status(context.Context) opskit.Status { + if h == nil || h.manager == nil { + return opskit.NotReadyStatus("config reload command handler is missing manager", opskit.Attr("command", "reload")) + } + return opskit.ReadyStatus("config reload command handler ready", opskit.Attr("command", "reload")) +} + +// Commands returns the reload command descriptor. +func (h *ReloadCommandHandler[T]) Commands(context.Context) []opskit.CommandDescriptor { + if h == nil { + return nil + } + + descriptor := h.descriptor + descriptor.Attributes = cloneOpsAttributes(descriptor.Attributes) + return []opskit.CommandDescriptor{descriptor} +} + +// HandleCommand runs the Configkit reload command. +func (h *ReloadCommandHandler[T]) HandleCommand(ctx context.Context, request opskit.CommandRequest) opskit.CommandResult { + if ctx == nil { + ctx = context.Background() + } + startedAt := time.Now() + + if h == nil || h.manager == nil { + return opskit.RejectedCommand("config reload command handler is missing manager", opskit.Attr("command", "reload")) + } + if request.Name != h.descriptor.Name { + return opskit.RejectedCommand("unsupported config reload command", opskit.Attr("command", "reload")) + } + + result, loadErr := h.manager.LoadFromSource(ctx, AttemptKindReload, h.source, h.pipeline) + duration := time.Since(startedAt) + if isReloadCommandContextError(loadErr) { + contextErr := reloadCommandContextError(loadErr) + return opskit.FailedCommand(reloadCommandContextMessage(contextErr), contextErr, duration, opskit.Attr("command", "reload")) + } + + status := h.manager.LifecycleStatus() + payload := NewReloadCommandResult(result, status, loadErr) + return opskit.CompletedCommand(reloadCommandMessage(result.Load.Attempt.Status), payload, duration, opskit.Attr("command", "reload")) +} + +// ReloadCommandResult is the operational result payload for a Configkit reload +// command. +// +// It intentionally excludes typed configuration values and redacted inspection +// output. Revisions, checksums, and normal returned error strings are still +// operationally visible and should be safe for the command audience. Recovered +// panic payloads are sanitized into safe stage-specific messages. +type ReloadCommandResult struct { + AttemptID uint64 `json:"attempt_id,omitempty"` + AttemptStatus AttemptStatus `json:"attempt_status"` + ManagerState LifecycleState `json:"manager_state"` + Published bool `json:"published"` + Changed bool `json:"changed"` + CurrentChecksum string `json:"current_checksum,omitempty"` + CurrentRevision string `json:"current_revision,omitempty"` + Error string `json:"error,omitempty"` +} + +// NewReloadCommandResult builds the safe operational reload command payload. +func NewReloadCommandResult[T any](result ManagedLoadResult[T], status LifecycleStatus, loadErr error) ReloadCommandResult { + payload := ReloadCommandResult{ + AttemptID: result.Load.Attempt.ID, + AttemptStatus: result.Load.Attempt.Status, + ManagerState: status.State, + Published: result.Apply.Published, + Changed: result.Apply.Changed, + } + if result.Apply.Current != nil { + payload.CurrentChecksum = result.Apply.Current.Checksum + payload.CurrentRevision = result.Apply.Current.Revision + } + if loadErr != nil { + payload.Error = loadErr.Error() + } + return payload +} + +func isReloadCommandContextError(err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} + +func reloadCommandContextError(err error) error { + switch { + case errors.Is(err, context.Canceled): + return context.Canceled + case errors.Is(err, context.DeadlineExceeded): + return context.DeadlineExceeded + default: + return err + } +} + +func reloadCommandContextMessage(err error) string { + switch { + case errors.Is(err, context.Canceled): + return "config reload canceled" + case errors.Is(err, context.DeadlineExceeded): + return "config reload deadline exceeded" + default: + return "config reload failed" + } +} + +func reloadCommandMessage(status AttemptStatus) string { + if status == AttemptStatusSucceeded { + return "config reload succeeded" + } + return "config reload failed" +} diff --git a/reload_command_test.go b/reload_command_test.go new file mode 100644 index 0000000..a49caf5 --- /dev/null +++ b/reload_command_test.go @@ -0,0 +1,360 @@ +package configkit_test + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + configkit "github.com/jaredjakacky/configkit" + opskit "github.com/jaredjakacky/opskit" +) + +type reloadCommandTestConfig struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Port int `json:"port"` +} + +func TestReloadCommandDefaults(t *testing.T) { + command := configkit.ReloadCommand( + configkit.NewManager[reloadCommandTestConfig](), + configkit.NewBytesSource([]byte(`{"name":"api","enabled":true,"port":8080}`), configkit.SourceMetadata{}, "rev-1"), + reloadCommandTestPipeline(), + ) + + info := command.ComponentInfo() + if info.Name != "config-reload" { + t.Fatalf("component name = %q, want config-reload", info.Name) + } + if info.Kind != "command_handler" { + t.Fatalf("component kind = %q, want command_handler", info.Kind) + } + if command.Status(context.Background()).State != opskit.StateReady { + t.Fatalf("status = %+v, want ready", command.Status(context.Background())) + } + + commands := command.Commands(context.Background()) + if len(commands) != 1 { + t.Fatalf("command count = %d, want 1", len(commands)) + } + if commands[0].Name != "config/reload" { + t.Fatalf("command name = %q, want config/reload", commands[0].Name) + } + if commands[0].Description != "reloads Configkit configuration from source" { + t.Fatalf("description = %q, want default", commands[0].Description) + } + if !commands[0].Dangerous { + t.Fatal("dangerous = false, want true") + } + if commands[0].Idempotent { + t.Fatal("idempotent = true, want false") + } +} + +func TestReloadCommandOptions(t *testing.T) { + command := configkit.ReloadCommand( + configkit.NewManager[reloadCommandTestConfig](), + configkit.NewBytesSource([]byte(`{}`), configkit.SourceMetadata{}, "rev-1"), + reloadCommandTestPipeline(), + configkit.WithReloadCommandName("admin/config/reload"), + configkit.WithReloadCommandDescription("reload config"), + configkit.WithReloadCommandComponentInfo(opskit.ComponentInfo{ + Name: "admin-config-reload", + Kind: "admin_command", + Description: "admin reload command", + Labels: []opskit.Attribute{opskit.Attr("owner", "platform")}, + }), + ) + + if got := command.ComponentInfo().Name; got != "admin-config-reload" { + t.Fatalf("component name = %q, want admin-config-reload", got) + } + commands := command.Commands(context.Background()) + if commands[0].Name != "admin/config/reload" { + t.Fatalf("command name = %q, want admin/config/reload", commands[0].Name) + } + if commands[0].Description != "reload config" { + t.Fatalf("description = %q, want reload config", commands[0].Description) + } +} + +func TestReloadCommandUnsupportedNameRejected(t *testing.T) { + command := configkit.ReloadCommand( + configkit.NewManager[reloadCommandTestConfig](), + configkit.NewBytesSource([]byte(`{"name":"api","enabled":true,"port":8080}`), configkit.SourceMetadata{}, "rev-1"), + reloadCommandTestPipeline(), + ) + + result := command.HandleCommand(context.Background(), opskit.NewCommandRequest("other/reload", nil)) + if result.Accepted { + t.Fatalf("accepted = true, want false") + } + if result.State != opskit.StateNotReady { + t.Fatalf("state = %s, want not ready", result.State) + } + if result.Result != nil { + t.Fatalf("result = %+v, want nil", result.Result) + } +} + +func TestReloadCommandSuccessfulReloadPayload(t *testing.T) { + manager := configkit.NewManager[reloadCommandTestConfig]() + source := configkit.NewBytesSource( + []byte(`{"name":"api","enabled":true,"port":8080}`), + configkit.SourceMetadata{Name: "memory", Kind: "memory"}, + "rev-1", + ) + command := configkit.ReloadCommand(manager, source, reloadCommandTestPipeline()) + + result := command.HandleCommand(context.Background(), opskit.NewCommandRequest("config/reload", nil)) + if result.State != opskit.StateReady { + t.Fatalf("state = %s, want ready", result.State) + } + if !result.Accepted { + t.Fatal("accepted = false, want true") + } + if result.Message != "config reload succeeded" { + t.Fatalf("message = %q, want success message", result.Message) + } + + payload := reloadCommandPayload(t, result) + if payload.AttemptID == 0 { + t.Fatal("attempt id = 0, want manager-assigned id") + } + if payload.AttemptStatus != configkit.AttemptStatusSucceeded { + t.Fatalf("attempt status = %q, want succeeded", payload.AttemptStatus) + } + if payload.ManagerState != configkit.LifecycleStateLoaded { + t.Fatalf("manager state = %q, want loaded", payload.ManagerState) + } + if !payload.Published || !payload.Changed { + t.Fatalf("published/changed = %v/%v, want true/true", payload.Published, payload.Changed) + } + if payload.CurrentChecksum == "" { + t.Fatal("current checksum = empty, want checksum") + } + if payload.CurrentRevision != "rev-1" { + t.Fatalf("current revision = %q, want rev-1", payload.CurrentRevision) + } + if payload.Error != "" { + t.Fatalf("error = %q, want empty", payload.Error) + } +} + +func TestReloadCommandFailedReloadReturnsCompletedResult(t *testing.T) { + manager := loadedReloadCommandManager(t) + failingSource := configkit.NewBytesSource( + []byte(`{"name":`), + configkit.SourceMetadata{Name: "memory", Kind: "memory"}, + "rev-2", + ) + command := configkit.ReloadCommand(manager, failingSource, reloadCommandTestPipeline()) + + result := command.HandleCommand(context.Background(), opskit.NewCommandRequest("config/reload", nil)) + if result.State != opskit.StateReady { + t.Fatalf("state = %s, want ready completed command", result.State) + } + if !result.Accepted { + t.Fatal("accepted = false, want true") + } + if result.Message != "config reload failed" { + t.Fatalf("message = %q, want failure message", result.Message) + } + + payload := reloadCommandPayload(t, result) + if payload.AttemptStatus != configkit.AttemptStatusFailed { + t.Fatalf("attempt status = %q, want failed", payload.AttemptStatus) + } + if payload.ManagerState != configkit.LifecycleStateDegraded { + t.Fatalf("manager state = %q, want degraded", payload.ManagerState) + } + if payload.Published || payload.Changed { + t.Fatalf("published/changed = %v/%v, want false/false", payload.Published, payload.Changed) + } + if payload.CurrentRevision != "rev-1" { + t.Fatalf("current revision = %q, want last-known-good rev-1", payload.CurrentRevision) + } + if payload.Error == "" { + t.Fatal("error = empty, want failure details") + } +} + +func TestReloadCommandContextCanceledReturnsFailedCommand(t *testing.T) { + manager := configkit.NewManager[reloadCommandTestConfig]() + source := configkit.NewBytesSource( + []byte(`{"name":"api","enabled":true,"port":8080}`), + configkit.SourceMetadata{Name: "memory", Kind: "memory"}, + "rev-1", + ) + command := configkit.ReloadCommand(manager, source, reloadCommandTestPipeline()) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result := command.HandleCommand(ctx, opskit.NewCommandRequest("config/reload", nil)) + if result.State != opskit.StateFailed { + t.Fatalf("state = %s, want failed", result.State) + } + if !result.Accepted { + t.Fatal("accepted = false, want true") + } + if result.Error != context.Canceled.Error() { + t.Fatalf("error = %q, want context canceled", result.Error) + } + if result.Result != nil { + t.Fatalf("result = %+v, want nil", result.Result) + } +} + +func TestReloadCommandContextDeadlineReturnsFailedCommand(t *testing.T) { + manager := configkit.NewManager[reloadCommandTestConfig]() + source := configkit.NewBytesSource( + []byte(`{"name":"api","enabled":true,"port":8080}`), + configkit.SourceMetadata{Name: "memory", Kind: "memory"}, + "rev-1", + ) + command := configkit.ReloadCommand(manager, source, reloadCommandTestPipeline()) + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + + result := command.HandleCommand(ctx, opskit.NewCommandRequest("config/reload", nil)) + if result.State != opskit.StateFailed { + t.Fatalf("state = %s, want failed", result.State) + } + if result.Error != context.DeadlineExceeded.Error() { + t.Fatalf("error = %q, want deadline exceeded", result.Error) + } + if result.Result != nil { + t.Fatalf("result = %+v, want nil", result.Result) + } +} + +func TestReloadCommandMissingManagerRejected(t *testing.T) { + command := configkit.ReloadCommand(nil, nil, reloadCommandTestPipeline()) + + if command.Status(context.Background()).State != opskit.StateNotReady { + t.Fatalf("status = %+v, want not ready", command.Status(context.Background())) + } + result := command.HandleCommand(context.Background(), opskit.NewCommandRequest("config/reload", nil)) + if result.Accepted { + t.Fatal("accepted = true, want false") + } + if result.Result != nil { + t.Fatalf("result = %+v, want nil", result.Result) + } +} + +func TestReloadCommandMissingSourceReturnsFailurePayload(t *testing.T) { + manager := configkit.NewManager[reloadCommandTestConfig]() + command := configkit.ReloadCommand(manager, nil, reloadCommandTestPipeline()) + + result := command.HandleCommand(context.Background(), opskit.NewCommandRequest("config/reload", nil)) + if result.State != opskit.StateReady { + t.Fatalf("state = %s, want completed command", result.State) + } + payload := reloadCommandPayload(t, result) + if payload.AttemptStatus != configkit.AttemptStatusFailed { + t.Fatalf("attempt status = %q, want failed", payload.AttemptStatus) + } + if payload.ManagerState != configkit.LifecycleStateFailed { + t.Fatalf("manager state = %q, want failed", payload.ManagerState) + } + if !strings.Contains(payload.Error, configkit.ErrMissingSource.Error()) { + t.Fatalf("payload error = %q, want missing source", payload.Error) + } +} + +func TestReloadCommandPanicPayloadIsSanitized(t *testing.T) { + const secret = "postgres://user:pass@example/db" + manager := configkit.NewManager[reloadCommandTestConfig]() + source := configkit.NewBytesSource( + []byte(`{"name":"api","enabled":true,"port":8080}`), + configkit.SourceMetadata{Name: "memory", Kind: "memory"}, + "rev-1", + ) + pipeline := reloadCommandTestPipeline() + pipeline.ValidateConfig = func(ctx context.Context, cfg reloadCommandTestConfig) error { + panic(errors.New(secret)) + } + command := configkit.ReloadCommand(manager, source, pipeline) + + result := command.HandleCommand(context.Background(), opskit.NewCommandRequest("config/reload", nil)) + encoded, err := json.Marshal(result) + if err != nil { + t.Fatalf("marshal command result: %v", err) + } + if strings.Contains(string(encoded), secret) { + t.Fatalf("command result = %s, must not contain %q", encoded, secret) + } + payload := reloadCommandPayload(t, result) + if payload.Error != "validate config panicked" { + t.Fatalf("payload error = %q, want safe panic message", payload.Error) + } +} + +func TestReloadCommandPayloadDoesNotIncludeConfigOrRedactedInspection(t *testing.T) { + manager := configkit.NewManager[reloadCommandTestConfig]() + source := configkit.NewBytesSource( + []byte(`{"name":"api","enabled":true,"port":8080}`), + configkit.SourceMetadata{Name: "memory", Kind: "memory"}, + "rev-1", + ) + pipeline := reloadCommandTestPipeline() + pipeline.Redact = func(ctx context.Context, cfg reloadCommandTestConfig) (configkit.RedactedView, error) { + return configkit.RedactedView{"redacted_name": cfg.Name}, nil + } + command := configkit.ReloadCommand(manager, source, pipeline) + + result := command.HandleCommand(context.Background(), opskit.NewCommandRequest("config/reload", nil)) + encoded, err := json.Marshal(result.Result) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + body := string(encoded) + for _, unsafe := range []string{"api", "enabled", "port", "redacted_name"} { + if strings.Contains(body, unsafe) { + t.Fatalf("payload = %s, must not contain %q", body, unsafe) + } + } +} + +func loadedReloadCommandManager(t *testing.T) *configkit.Manager[reloadCommandTestConfig] { + t.Helper() + + manager := configkit.NewManager[reloadCommandTestConfig]() + source := configkit.NewBytesSource( + []byte(`{"name":"api","enabled":true,"port":8080}`), + configkit.SourceMetadata{Name: "memory", Kind: "memory"}, + "rev-1", + ) + if _, err := manager.LoadFromSource(context.Background(), configkit.AttemptKindInitialLoad, source, reloadCommandTestPipeline()); err != nil { + t.Fatalf("initial load: %v", err) + } + return manager +} + +func reloadCommandTestPipeline() configkit.Pipeline[reloadCommandTestConfig] { + return configkit.Pipeline[reloadCommandTestConfig]{ + Decode: configkit.JSONDecoder[reloadCommandTestConfig](), + ValidateConfig: func(ctx context.Context, cfg reloadCommandTestConfig) error { + if cfg.Name == "" { + return errors.New("name is required") + } + return nil + }, + Redact: configkit.EmptyRedactor[reloadCommandTestConfig](), + Checksum: configkit.SHA256JSONChecksum[reloadCommandTestConfig](), + } +} + +func reloadCommandPayload(t *testing.T, result opskit.CommandResult) configkit.ReloadCommandResult { + t.Helper() + + payload, ok := result.Result.(configkit.ReloadCommandResult) + if !ok { + t.Fatalf("result payload type = %T, want configkit.ReloadCommandResult", result.Result) + } + return payload +} diff --git a/worker/reload.go b/worker/reload.go index 429ab17..5da129e 100644 --- a/worker/reload.go +++ b/worker/reload.go @@ -4,9 +4,9 @@ import ( "context" "encoding/json" "errors" - "fmt" configkit "github.com/jaredjakacky/configkit" + opskit "github.com/jaredjakacky/opskit" workerkit "github.com/jaredjakacky/workerkit" ) @@ -59,11 +59,21 @@ func ReloadCommand[T any](manager *configkit.Manager[T], source configkit.Source } } + command := configkit.ReloadCommand(manager, source, pipeline, + configkit.WithReloadCommandName(options.name), + configkit.WithReloadCommandDescription(options.description), + ) + return workerkit.CommandSpec{ Name: options.name, Description: options.description, Handler: workerkit.CommandHandlerFunc(func(ctx context.Context, req workerkit.CommandRequest) (workerkit.CommandResult, error) { - return runReloadCommand(ctx, manager, source, pipeline) + result := command.HandleCommand(ctx, opskit.CommandRequest{ + Name: req.Name, + Payload: json.RawMessage(req.Payload), + RequestedAt: &req.RequestedAt, + }) + return workerResultFromOpskitResult(result) }), } } @@ -75,66 +85,32 @@ func defaultReloadCommandOptions() reloadCommandOptions { } } -func runReloadCommand[T any](ctx context.Context, manager *configkit.Manager[T], source configkit.Source, pipeline configkit.Pipeline[T]) (workerkit.CommandResult, error) { - if manager == nil { - return workerkit.CommandResult{}, errors.New("configkit/worker: missing manager") +func workerResultFromOpskitResult(result opskit.CommandResult) (workerkit.CommandResult, error) { + if !result.Accepted { + if result.Message != "" { + return workerkit.CommandResult{}, errors.New(result.Message) + } + return workerkit.CommandResult{}, errors.New("configkit/worker: command was not accepted") } - - result, loadErr := manager.LoadFromSource(ctx, configkit.AttemptKindReload, source, pipeline) - if isCommandContextError(loadErr) { - return workerkit.CommandResult{}, loadErr + if result.State == opskit.StateFailed && result.Result == nil { + switch result.Error { + case context.Canceled.Error(): + return workerkit.CommandResult{}, context.Canceled + case context.DeadlineExceeded.Error(): + return workerkit.CommandResult{}, context.DeadlineExceeded + case "": + return workerkit.CommandResult{}, errors.New(result.Message) + default: + return workerkit.CommandResult{}, errors.New(result.Error) + } } - status := manager.LifecycleStatus() - - payload, err := json.Marshal(reloadCommandPayload(result, status, loadErr)) + payload, err := json.Marshal(result.Result) if err != nil { - return workerkit.CommandResult{}, fmt.Errorf("encode config reload result: %w", err) + return workerkit.CommandResult{}, errors.New("encode config reload result: " + err.Error()) } - - commandResult := workerkit.CommandResult{ - Message: reloadCommandMessage(result.Load.Attempt.Status), + return workerkit.CommandResult{ + Message: result.Message, Payload: payload, - } - return commandResult, nil -} - -func isCommandContextError(err error) bool { - return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) -} - -type reloadResultPayload struct { - AttemptID uint64 `json:"attempt_id,omitempty"` - AttemptStatus configkit.AttemptStatus `json:"attempt_status"` - ManagerState configkit.LifecycleState `json:"manager_state"` - Published bool `json:"published"` - Changed bool `json:"changed"` - CurrentChecksum string `json:"current_checksum,omitempty"` - CurrentRevision string `json:"current_revision,omitempty"` - Error string `json:"error,omitempty"` -} - -func reloadCommandPayload[T any](result configkit.ManagedLoadResult[T], status configkit.LifecycleStatus, loadErr error) reloadResultPayload { - payload := reloadResultPayload{ - AttemptID: result.Load.Attempt.ID, - AttemptStatus: result.Load.Attempt.Status, - ManagerState: status.State, - Published: result.Apply.Published, - Changed: result.Apply.Changed, - } - if result.Apply.Current != nil { - payload.CurrentChecksum = result.Apply.Current.Checksum - payload.CurrentRevision = result.Apply.Current.Revision - } - if loadErr != nil { - payload.Error = loadErr.Error() - } - return payload -} - -func reloadCommandMessage(status configkit.AttemptStatus) string { - if status == configkit.AttemptStatusSucceeded { - return "config reload succeeded" - } - return "config reload failed" + }, nil }