Implement catalog startup diagnostics (T-616)
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrCatalogSourceUnconfigured means this build has no trusted Catalog
|
||||
// endpoint/key composition. It must never be replaced with test data.
|
||||
ErrCatalogSourceUnconfigured = errors.New("catalog source is unconfigured")
|
||||
ErrCatalogBootstrapInvalid = errors.New("catalog bootstrap is invalid")
|
||||
ErrCatalogEventPayload = errors.New("catalog event payload is invalid")
|
||||
)
|
||||
|
||||
// CatalogSource identifies the verified source that produced a snapshot.
|
||||
type CatalogSource string
|
||||
|
||||
const (
|
||||
CatalogSourceRemote CatalogSource = "remote"
|
||||
CatalogSourceCache CatalogSource = "cache"
|
||||
)
|
||||
|
||||
// CatalogFailureCode is the public, non-sensitive reason shown by adapters.
|
||||
type CatalogFailureCode string
|
||||
|
||||
const (
|
||||
CatalogFailureSourceUnconfigured CatalogFailureCode = "catalog_source_unconfigured"
|
||||
CatalogFailureLoadFailed CatalogFailureCode = "catalog_load_failed"
|
||||
)
|
||||
|
||||
// CatalogSnapshot is an IO-free, verified and target-filtered list prepared
|
||||
// before it crosses into the UI event boundary.
|
||||
type CatalogSnapshot struct {
|
||||
Items []CatalogListItem
|
||||
Source CatalogSource
|
||||
}
|
||||
|
||||
// CatalogSnapshotLoader prepares an already verified in-memory snapshot. Its
|
||||
// implementation belongs in composition/infrastructure, never in Gio Layout.
|
||||
type CatalogSnapshotLoader interface {
|
||||
LoadCatalogSnapshot(context.Context) (CatalogSnapshot, error)
|
||||
}
|
||||
|
||||
// CatalogSnapshotLoaderFunc adapts a function to CatalogSnapshotLoader.
|
||||
type CatalogSnapshotLoaderFunc func(context.Context) (CatalogSnapshot, error)
|
||||
|
||||
func (function CatalogSnapshotLoaderFunc) LoadCatalogSnapshot(ctx context.Context) (CatalogSnapshot, error) {
|
||||
return function(ctx)
|
||||
}
|
||||
|
||||
// EventPublisher is the narrow runtime boundary used by CatalogBootstrap.
|
||||
type EventPublisher interface {
|
||||
Publish(context.Context, Event) error
|
||||
}
|
||||
|
||||
// CatalogEvent is the typed, sanitized payload accepted by Gio adapters.
|
||||
type CatalogEvent struct {
|
||||
Type EventType
|
||||
Items []CatalogListItem
|
||||
Source CatalogSource
|
||||
FailureCode CatalogFailureCode
|
||||
}
|
||||
|
||||
// CatalogBootstrap invokes one loader and publishes exactly one catalog result.
|
||||
// It is safe to run only in a background goroutine.
|
||||
type CatalogBootstrap struct {
|
||||
loader CatalogSnapshotLoader
|
||||
publisher EventPublisher
|
||||
}
|
||||
|
||||
// NewCatalogBootstrap creates the pure-core startup bridge.
|
||||
func NewCatalogBootstrap(loader CatalogSnapshotLoader, publisher EventPublisher) *CatalogBootstrap {
|
||||
return &CatalogBootstrap{loader: loader, publisher: publisher}
|
||||
}
|
||||
|
||||
// Run publishes a sanitized success or failure event. The returned error keeps
|
||||
// the original loader/publisher cause for non-UI diagnostics.
|
||||
func (bootstrap *CatalogBootstrap) Run(ctx context.Context) error {
|
||||
if bootstrap == nil || bootstrap.loader == nil || bootstrap.publisher == nil {
|
||||
return ErrCatalogBootstrapInvalid
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot, err := bootstrap.loader.LoadCatalogSnapshot(ctx)
|
||||
if err != nil {
|
||||
return bootstrap.publishFailure(ctx, err)
|
||||
}
|
||||
if err := validateCatalogSnapshot(snapshot); err != nil {
|
||||
return bootstrap.publishFailure(ctx, err)
|
||||
}
|
||||
return bootstrap.publisher.Publish(ctx, Event{
|
||||
Type: EventCatalogRefreshed,
|
||||
Payload: CatalogEvent{
|
||||
Type: EventCatalogRefreshed,
|
||||
Items: cloneCatalogItems(snapshot.Items),
|
||||
Source: snapshot.Source,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (bootstrap *CatalogBootstrap) publishFailure(ctx context.Context, cause error) error {
|
||||
publishErr := bootstrap.publisher.Publish(ctx, Event{
|
||||
Type: EventCatalogRejected,
|
||||
Payload: CatalogEvent{Type: EventCatalogRejected, FailureCode: catalogFailureCode(cause)},
|
||||
})
|
||||
if publishErr != nil {
|
||||
return errors.Join(cause, publishErr)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
// ParseCatalogEvent validates and deep-copies the payload before an adapter
|
||||
// changes UI state. Non-catalog events are left for other event handlers.
|
||||
func ParseCatalogEvent(event Event) (CatalogEvent, bool, error) {
|
||||
if event.Type != EventCatalogRefreshed && event.Type != EventCatalogRejected {
|
||||
return CatalogEvent{}, false, nil
|
||||
}
|
||||
payload, ok := event.Payload.(CatalogEvent)
|
||||
if !ok || payload.Type != event.Type || event.RequestID != "" || event.AppID != "" {
|
||||
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||
}
|
||||
switch payload.Type {
|
||||
case EventCatalogRefreshed:
|
||||
if payload.FailureCode != "" || validateCatalogSnapshot(CatalogSnapshot{Items: payload.Items, Source: payload.Source}) != nil {
|
||||
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||
}
|
||||
payload.Items = cloneCatalogItems(payload.Items)
|
||||
return payload, true, nil
|
||||
case EventCatalogRejected:
|
||||
if len(payload.Items) != 0 || payload.Source != "" || !payload.FailureCode.valid() {
|
||||
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||
}
|
||||
return payload, true, nil
|
||||
default:
|
||||
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||
}
|
||||
}
|
||||
|
||||
// UnconfiguredCatalogLoader is the fail-closed default for builds where the
|
||||
// trusted endpoint and public key have not been provisioned.
|
||||
type UnconfiguredCatalogLoader struct{}
|
||||
|
||||
func (UnconfiguredCatalogLoader) LoadCatalogSnapshot(ctx context.Context) (CatalogSnapshot, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return CatalogSnapshot{}, err
|
||||
}
|
||||
return CatalogSnapshot{}, ErrCatalogSourceUnconfigured
|
||||
}
|
||||
|
||||
func validateCatalogSnapshot(snapshot CatalogSnapshot) error {
|
||||
if !snapshot.Source.valid() {
|
||||
return fmt.Errorf("%w: unrecognized catalog source", ErrCatalogBootstrapInvalid)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(snapshot.Items))
|
||||
for _, item := range snapshot.Items {
|
||||
if item.ID == "" || item.Name == "" || item.Version == "" {
|
||||
return fmt.Errorf("%w: incomplete catalog item", ErrCatalogBootstrapInvalid)
|
||||
}
|
||||
if _, exists := seen[item.ID]; exists {
|
||||
return fmt.Errorf("%w: duplicate catalog item %q", ErrCatalogBootstrapInvalid, item.ID)
|
||||
}
|
||||
seen[item.ID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func catalogFailureCode(err error) CatalogFailureCode {
|
||||
if errors.Is(err, ErrCatalogSourceUnconfigured) {
|
||||
return CatalogFailureSourceUnconfigured
|
||||
}
|
||||
return CatalogFailureLoadFailed
|
||||
}
|
||||
|
||||
func (source CatalogSource) valid() bool {
|
||||
return source == CatalogSourceRemote || source == CatalogSourceCache
|
||||
}
|
||||
|
||||
func (code CatalogFailureCode) valid() bool {
|
||||
return code == CatalogFailureSourceUnconfigured || code == CatalogFailureLoadFailed
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
func TestCatalogBootstrapPublishesIndependentVerifiedSnapshot(t *testing.T) {
|
||||
items := []CatalogListItem{{
|
||||
ID: "json-tool", Name: "JSON Tool", Version: "1.0.0", Tags: []string{"json"}, Status: domain.StatusNotInstalled,
|
||||
}}
|
||||
runtime := NewRuntime(1)
|
||||
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
return CatalogSnapshot{Items: items, Source: CatalogSourceCache}, nil
|
||||
}), runtime)
|
||||
if err := bootstrap.Run(context.Background()); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
items[0].Name = "mutated"
|
||||
items[0].Tags[0] = "mutated"
|
||||
event := <-runtime.Events()
|
||||
payload, handled, err := ParseCatalogEvent(event)
|
||||
if err != nil || !handled {
|
||||
t.Fatalf("ParseCatalogEvent() = %#v, %v, %v", payload, handled, err)
|
||||
}
|
||||
if payload.Source != CatalogSourceCache || payload.Items[0].Name != "JSON Tool" || payload.Items[0].Tags[0] != "json" {
|
||||
t.Fatalf("payload = %#v, want independent verified snapshot", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogBootstrapPublishesStableFailureCode(t *testing.T) {
|
||||
loadErr := errors.New("network endpoint details must not reach UI")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
err error
|
||||
code CatalogFailureCode
|
||||
}{
|
||||
{name: "unconfigured", err: ErrCatalogSourceUnconfigured, code: CatalogFailureSourceUnconfigured},
|
||||
{name: "load failed", err: loadErr, code: CatalogFailureLoadFailed},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
runtime := NewRuntime(1)
|
||||
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
return CatalogSnapshot{}, test.err
|
||||
}), runtime)
|
||||
err := bootstrap.Run(context.Background())
|
||||
if !errors.Is(err, test.err) {
|
||||
t.Fatalf("Run() error = %v, want original loader error", err)
|
||||
}
|
||||
payload, handled, parseErr := ParseCatalogEvent(<-runtime.Events())
|
||||
if parseErr != nil || !handled || payload.FailureCode != test.code {
|
||||
t.Fatalf("failure payload = %#v, handled=%v, error=%v", payload, handled, parseErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogBootstrapFailsClosedForInvalidDependenciesAndPayload(t *testing.T) {
|
||||
if err := (*CatalogBootstrap)(nil).Run(context.Background()); !errors.Is(err, ErrCatalogBootstrapInvalid) {
|
||||
t.Fatalf("nil bootstrap error = %v", err)
|
||||
}
|
||||
runtime := NewRuntime(1)
|
||||
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
return CatalogSnapshot{Source: CatalogSourceRemote, Items: []CatalogListItem{{ID: "only-id"}}}, nil
|
||||
}), runtime)
|
||||
if err := bootstrap.Run(context.Background()); !errors.Is(err, ErrCatalogBootstrapInvalid) {
|
||||
t.Fatalf("invalid snapshot error = %v", err)
|
||||
}
|
||||
if _, _, err := ParseCatalogEvent(Event{Type: EventCatalogRejected, Payload: CatalogEvent{Type: EventCatalogRejected}}); !errors.Is(err, ErrCatalogEventPayload) {
|
||||
t.Fatalf("invalid rejection payload error = %v", err)
|
||||
}
|
||||
if _, _, err := ParseCatalogEvent(Event{Type: EventCatalogRefreshed, Payload: CatalogEvent{Type: EventCatalogRefreshed, Source: CatalogSourceRemote, Items: []CatalogListItem{{ID: "only-id"}}}}); !errors.Is(err, ErrCatalogEventPayload) {
|
||||
t.Fatalf("invalid refresh payload error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnconfiguredCatalogLoaderHonorsCanceledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := (UnconfiguredCatalogLoader{}).LoadCatalogSnapshot(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("LoadCatalogSnapshot() error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogBootstrapHonorsCancellationAndPreservesPublisherFailure(t *testing.T) {
|
||||
called := false
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
called = true
|
||||
return CatalogSnapshot{}, nil
|
||||
}), NewRuntime(1))
|
||||
if err := bootstrap.Run(ctx); !errors.Is(err, context.Canceled) || called {
|
||||
t.Fatalf("canceled Run() = %v, loader called=%v", err, called)
|
||||
}
|
||||
publishErr := errors.New("runtime publish unavailable")
|
||||
loaderErr := errors.New("loader failed")
|
||||
bootstrap = NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
return CatalogSnapshot{}, loaderErr
|
||||
}), catalogFailPublisher{err: publishErr})
|
||||
err := bootstrap.Run(context.Background())
|
||||
if !errors.Is(err, loaderErr) || !errors.Is(err, publishErr) {
|
||||
t.Fatalf("combined failure = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type catalogFailPublisher struct{ err error }
|
||||
|
||||
func (publisher catalogFailPublisher) Publish(context.Context, Event) error { return publisher.err }
|
||||
Reference in New Issue
Block a user