Implement catalog startup diagnostics (T-616)

This commit is contained in:
ila
2026-07-19 22:22:46 +08:00
parent 06234e11bf
commit 2c561894fd
28 changed files with 715 additions and 23 deletions
+183
View File
@@ -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
}
+112
View File
@@ -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 }