diff --git a/api/proto/meridian/control_plane/v1/manifest_history_service.proto b/api/proto/meridian/control_plane/v1/manifest_history_service.proto index 0d7c8139f..dc415b217 100644 --- a/api/proto/meridian/control_plane/v1/manifest_history_service.proto +++ b/api/proto/meridian/control_plane/v1/manifest_history_service.proto @@ -60,6 +60,16 @@ service ManifestHistoryService { get: "/v1/manifests/export" }; } + + // ReconcileManifest compares a stored manifest version against live service + // state and reports any drift. This is a read-only safety-net operation — + // no auto-repair is performed. With structural APIs removed from the public + // surface, drift should be impossible; reconciliation validates that invariant. + rpc ReconcileManifest(ReconcileManifestRequest) returns (ReconcileManifestResponse) { + option (google.api.http) = { + get: "/v1/manifests/reconcile" + }; + } } // ======================================== @@ -314,3 +324,99 @@ message ExportManifestResponse { // (e.g., a service was unreachable, partial data returned). repeated string warnings = 5; } + +// ======================================== +// Reconcile Manifest +// ======================================== + +// ReconcileManifestRequest specifies which stored manifest version to compare +// against live service state. +message ReconcileManifestRequest { + // version is the manifest version string to reconcile (e.g., "1.0"). + // If empty, the most recently applied manifest version is used. + string version = 1 [(buf.validate.field).string.max_len = 50]; + + // include_sections filters which manifest sections to reconcile. + // If empty, all sections are reconciled. Valid values match ExportManifestRequest. + repeated string include_sections = 2 [(buf.validate.field).repeated = { + max_items: 20 + items: { + string: { + min_len: 1 + max_len: 64 + } + } + }]; +} + +// ReconcileManifestResponse contains the drift report between the stored +// manifest and live service state. +message ReconcileManifestResponse { + // drift_items lists individual resource-level discrepancies found. + repeated DriftItem drift_items = 1; + + // summary contains aggregate counts of the reconciliation. + ReconcileSummary summary = 2; + + // reconciled_version is the manifest version string that was reconciled. + string reconciled_version = 3; + + // reconciled_at is the timestamp when reconciliation was performed. + google.protobuf.Timestamp reconciled_at = 4; + + // warnings contains non-fatal issues encountered during reconciliation + // (e.g., a live service was unreachable). + repeated string warnings = 5; +} + +// DriftType classifies the nature of a drift discrepancy. +enum DriftType { + // DRIFT_TYPE_UNSPECIFIED is the default zero value. + DRIFT_TYPE_UNSPECIFIED = 0; + + // DRIFT_TYPE_MISSING indicates a resource exists in the stored manifest + // but is absent from live service state. + DRIFT_TYPE_MISSING = 1; + + // DRIFT_TYPE_MODIFIED indicates a resource exists in both the stored manifest + // and live state but their definitions differ. + DRIFT_TYPE_MODIFIED = 2; + + // DRIFT_TYPE_EXTRA indicates a resource exists in live service state + // but is absent from the stored manifest. + DRIFT_TYPE_EXTRA = 3; +} + +// DriftItem represents a single resource-level discrepancy between the +// stored manifest and live service state. +message DriftItem { + // resource_type is the category of the resource (e.g., "instrument", "saga"). + string resource_type = 1; + + // resource_code is the unique identifier of the resource within its type. + string resource_code = 2; + + // drift_type classifies the nature of the discrepancy. + DriftType drift_type = 3; + + // description is a human-readable summary of the drift. + string description = 4; +} + +// ReconcileSummary contains aggregate statistics about a reconciliation run. +message ReconcileSummary { + // total_checked is the total number of resources compared. + int32 total_checked = 1; + + // total_drifted is the number of resources with discrepancies. + int32 total_drifted = 2; + + // missing is the number of resources in the manifest but absent live. + int32 missing = 3; + + // modified is the number of resources present in both but differing. + int32 modified = 4; + + // extra is the number of resources live but absent from the manifest. + int32 extra = 5; +} diff --git a/services/control-plane/internal/manifest/grpc_handler.go b/services/control-plane/internal/manifest/grpc_handler.go index ec69ecfd4..b5bcc6815 100644 --- a/services/control-plane/internal/manifest/grpc_handler.go +++ b/services/control-plane/internal/manifest/grpc_handler.go @@ -18,9 +18,10 @@ var ErrHistoryServiceRequired = errors.New("history service is required") type HistoryHandler struct { controlplanev1.UnimplementedManifestHistoryServiceServer - history *HistoryService - exporter *ExportService - logger *slog.Logger + history *HistoryService + exporter *ExportService + reconciler *ReconcileService + logger *slog.Logger } // NewHistoryHandler creates a new HistoryHandler. @@ -48,6 +49,17 @@ func NewHistoryHandlerWithExport(history *HistoryService, exporter *ExportServic return h, nil } +// NewHistoryHandlerWithReconcile creates a HistoryHandler with export and reconcile support. +// The reconciler enables the ReconcileManifest RPC; when nil, the RPC returns Unimplemented. +func NewHistoryHandlerWithReconcile(history *HistoryService, exporter *ExportService, reconciler *ReconcileService, logger *slog.Logger) (*HistoryHandler, error) { + h, err := NewHistoryHandlerWithExport(history, exporter, logger) + if err != nil { + return nil, err + } + h.reconciler = reconciler + return h, nil +} + // GetCurrentManifest retrieves the most recently applied manifest for the tenant. func (h *HistoryHandler) GetCurrentManifest( ctx context.Context, @@ -217,3 +229,28 @@ func (h *HistoryHandler) ExportManifest( return result.ToProtoResponse(), nil } + +// ReconcileManifest compares a stored manifest against live service state +// and reports any drift as structured output. +func (h *HistoryHandler) ReconcileManifest( + ctx context.Context, + req *controlplanev1.ReconcileManifestRequest, +) (*controlplanev1.ReconcileManifestResponse, error) { + if h.reconciler == nil { + return nil, status.Error(codes.Unimplemented, "reconcile manifest not configured") + } + + result, err := h.reconciler.Reconcile(ctx, req.GetVersion(), req.GetIncludeSections()) + if err != nil { + if errors.Is(err, ErrVersionNotFound) { + return nil, status.Error(codes.NotFound, "manifest version not found") + } + h.logger.Error("failed to reconcile manifest", + "version", req.GetVersion(), + "error", err, + ) + return nil, status.Error(codes.Internal, "failed to reconcile manifest") + } + + return result.ToProtoResponse(), nil +} diff --git a/services/control-plane/internal/manifest/reconcile.go b/services/control-plane/internal/manifest/reconcile.go new file mode 100644 index 000000000..157460c82 --- /dev/null +++ b/services/control-plane/internal/manifest/reconcile.go @@ -0,0 +1,287 @@ +package manifest + +import ( + "context" + "errors" + "fmt" + "time" + + controlplanev1 "github.com/meridianhub/meridian/api/proto/meridian/control_plane/v1" + "github.com/meridianhub/meridian/services/control-plane/internal/differ" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// ReconcileResult holds the output of a reconciliation operation. +type ReconcileResult struct { + DriftItems []DriftItem + Summary ReconcileSummary + ReconciledVersion string + ReconciledAt time.Time + Warnings []string +} + +// DriftItem represents a single resource-level discrepancy between the +// stored manifest and live service state. +type DriftItem struct { + ResourceType string + ResourceCode string + DriftType DriftItemType + Description string +} + +// DriftItemType classifies the nature of a drift discrepancy. +type DriftItemType string + +const ( + // DriftTypeMissing indicates a resource exists in the stored manifest + // but is absent from live service state. + DriftTypeMissing DriftItemType = "MISSING" + + // DriftTypeModified indicates a resource exists in both but their + // definitions differ. + DriftTypeModified DriftItemType = "MODIFIED" + + // DriftTypeExtra indicates a resource exists in live service state + // but is absent from the stored manifest. + DriftTypeExtra DriftItemType = "EXTRA" +) + +// ReconcileSummary contains aggregate statistics about a reconciliation run. +type ReconcileSummary struct { + TotalChecked int + TotalDrifted int + Missing int + Modified int + Extra int +} + +// ErrNilExporter is returned when exporter is nil. +var ErrNilExporter = errors.New("exporter cannot be nil") + +// ReconcileService compares stored manifest state against live service state +// to detect drift. This is a read-only operation — no auto-repair is performed. +type ReconcileService struct { + history *HistoryService + exporter *ExportService + d *differ.ManifestDiffer +} + +// NewReconcileService creates a new ReconcileService. +// history is required for loading stored manifests. +// exporter is required for querying live state. +// d is the differ used for comparison; if nil a no-op differ is used. +func NewReconcileService(history *HistoryService, exporter *ExportService, d *differ.ManifestDiffer) (*ReconcileService, error) { + if history == nil { + return nil, ErrHistoryServiceRequired + } + if exporter == nil { + return nil, ErrNilExporter + } + if d == nil { + d = differ.New(nil, nil) + } + return &ReconcileService{ + history: history, + exporter: exporter, + d: d, + }, nil +} + +// Reconcile compares the stored manifest against live service state and +// reports any drift. version specifies which stored manifest to reconcile; +// empty means the latest applied. includeSections filters which sections +// are compared; empty means all. +func (s *ReconcileService) Reconcile(ctx context.Context, version string, includeSections []string) (*ReconcileResult, error) { + // Load the stored manifest. + storedManifest, storedVersion, err := s.loadStoredManifest(ctx, version) + if err != nil { + return nil, fmt.Errorf("load stored manifest: %w", err) + } + + // Export live state using the same section filter. + exportResult, err := s.exporter.Export(ctx, includeSections, storedVersion) + if err != nil { + return nil, fmt.Errorf("export live state: %w", err) + } + + // If include_sections is specified, zero out sections not in the filter + // on the stored manifest so the differ only sees requested sections. + filteredStored := storedManifest + if len(includeSections) > 0 { + filteredStored = filterManifestSections(storedManifest, includeSections) + } + + // Diff stored (as "last-applied") against live (as "new") to detect drift. + // Stored=old, Live=new: + // - DELETE actions mean resource in stored but not live -> MISSING + // - CREATE actions mean resource in live but not stored -> EXTRA + // - UPDATE actions mean resource differs -> MODIFIED + plan, err := s.d.Diff(ctx, filteredStored, exportResult.Manifest, differ.WithSkipSafetyChecks()) + if err != nil { + return nil, fmt.Errorf("diff failed: %w", err) + } + + result := diffPlanToReconcileResult(plan, storedVersion) + result.ReconciledAt = time.Now().UTC() + result.Warnings = exportResult.Warnings + + return result, nil +} + +// diffPlanToReconcileResult converts a differ.DiffPlan to a ReconcileResult. +// The mapping from diff actions to drift types: +// - DELETE (in stored but not in live) -> MISSING +// - CREATE (in live but not in stored) -> EXTRA +// - UPDATE (both exist but differ) -> MODIFIED +// - NO_CHANGE -> no drift (counted in TotalChecked) +func diffPlanToReconcileResult(plan *differ.DiffPlan, version string) *ReconcileResult { + result := &ReconcileResult{ + ReconciledVersion: version, + } + + for _, action := range plan.Actions { + switch action.Action { + case differ.ActionDelete: + result.DriftItems = append(result.DriftItems, DriftItem{ + ResourceType: string(action.ResourceType), + ResourceCode: action.ResourceCode, + DriftType: DriftTypeMissing, + Description: fmt.Sprintf("Resource %s %s exists in manifest but not in live state", action.ResourceType, action.ResourceCode), + }) + result.Summary.Missing++ + result.Summary.TotalDrifted++ + case differ.ActionCreate: + result.DriftItems = append(result.DriftItems, DriftItem{ + ResourceType: string(action.ResourceType), + ResourceCode: action.ResourceCode, + DriftType: DriftTypeExtra, + Description: fmt.Sprintf("Resource %s %s exists in live state but not in manifest", action.ResourceType, action.ResourceCode), + }) + result.Summary.Extra++ + result.Summary.TotalDrifted++ + case differ.ActionUpdate: + result.DriftItems = append(result.DriftItems, DriftItem{ + ResourceType: string(action.ResourceType), + ResourceCode: action.ResourceCode, + DriftType: DriftTypeModified, + Description: action.Description, + }) + result.Summary.Modified++ + result.Summary.TotalDrifted++ + case differ.ActionNoChange: + // No drift for this resource. + } + result.Summary.TotalChecked++ + } + + return result +} + +// loadStoredManifest loads the stored manifest to reconcile against. +func (s *ReconcileService) loadStoredManifest(ctx context.Context, version string) (*controlplanev1.Manifest, string, error) { + var entity *VersionEntity + var err error + + if version != "" { + entity, err = s.history.GetManifestVersion(ctx, version) + } else { + entity, err = s.history.GetCurrentManifest(ctx) + } + if err != nil { + return nil, "", err + } + + manifest, err := unmarshalManifest(entity.ManifestJSON) + if err != nil { + return nil, "", fmt.Errorf("unmarshal stored manifest: %w", err) + } + return manifest, entity.Version, nil +} + +// filterManifestSections returns a copy of the manifest with only the +// specified sections populated. Non-matching sections are left nil. +func filterManifestSections(m *controlplanev1.Manifest, includeSections []string) *controlplanev1.Manifest { + sections := parseSections(includeSections) + + filtered := &controlplanev1.Manifest{ + Version: m.Version, + Metadata: m.Metadata, + } + + if sections[SectionInstruments] { + filtered.Instruments = m.Instruments + } + if sections[SectionAccountTypes] { + filtered.AccountTypes = m.AccountTypes + } + if sections[SectionValuationRules] { + filtered.ValuationRules = m.ValuationRules + } + if sections[SectionSagas] { + filtered.Sagas = m.Sagas + } + if sections[SectionMarketData] { + filtered.MarketData = m.MarketData + } + if sections[SectionOrganizations] { + filtered.Organizations = m.Organizations + } + if sections[SectionInternalAccounts] { + filtered.InternalAccounts = m.InternalAccounts + } + if sections[SectionOperationalGateway] { + filtered.OperationalGateway = m.OperationalGateway + } + if sections[SectionMappings] { + filtered.Mappings = m.Mappings + } + if sections[SectionPartyTypes] { + filtered.PartyTypes = m.PartyTypes + } + if sections[SectionPaymentRails] { + filtered.PaymentRails = m.PaymentRails + } + + return filtered +} + +// ToProtoResponse converts a ReconcileResult to the gRPC response. +func (r *ReconcileResult) ToProtoResponse() *controlplanev1.ReconcileManifestResponse { + resp := &controlplanev1.ReconcileManifestResponse{ + ReconciledVersion: r.ReconciledVersion, + ReconciledAt: timestamppb.New(r.ReconciledAt), + Warnings: r.Warnings, + Summary: &controlplanev1.ReconcileSummary{ + TotalChecked: int32(r.Summary.TotalChecked), + TotalDrifted: int32(r.Summary.TotalDrifted), + Missing: int32(r.Summary.Missing), + Modified: int32(r.Summary.Modified), + Extra: int32(r.Summary.Extra), + }, + } + + for _, item := range r.DriftItems { + resp.DriftItems = append(resp.DriftItems, &controlplanev1.DriftItem{ + ResourceType: item.ResourceType, + ResourceCode: item.ResourceCode, + DriftType: toDriftTypeProto(item.DriftType), + Description: item.Description, + }) + } + + return resp +} + +// toDriftTypeProto converts an internal DriftItemType to the proto enum. +func toDriftTypeProto(dt DriftItemType) controlplanev1.DriftType { + switch dt { + case DriftTypeMissing: + return controlplanev1.DriftType_DRIFT_TYPE_MISSING + case DriftTypeModified: + return controlplanev1.DriftType_DRIFT_TYPE_MODIFIED + case DriftTypeExtra: + return controlplanev1.DriftType_DRIFT_TYPE_EXTRA + default: + return controlplanev1.DriftType_DRIFT_TYPE_UNSPECIFIED + } +} diff --git a/services/control-plane/internal/manifest/reconcile_test.go b/services/control-plane/internal/manifest/reconcile_test.go new file mode 100644 index 000000000..76a36c476 --- /dev/null +++ b/services/control-plane/internal/manifest/reconcile_test.go @@ -0,0 +1,398 @@ +package manifest + +import ( + "context" + "testing" + "time" + + controlplanev1 "github.com/meridianhub/meridian/api/proto/meridian/control_plane/v1" + "github.com/meridianhub/meridian/services/control-plane/internal/differ" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// --- NewReconcileService tests --- + +func TestNewReconcileService_NilHistory(t *testing.T) { + _, err := NewReconcileService(nil, &ExportService{}, nil) + assert.ErrorIs(t, err, ErrHistoryServiceRequired) +} + +func TestNewReconcileService_NilExporter(t *testing.T) { + repo := &Repository{} + svc, err := NewHistoryService(repo) + require.NoError(t, err) + + _, err = NewReconcileService(svc, nil, nil) + assert.ErrorIs(t, err, ErrNilExporter) +} + +func TestNewReconcileService_NilDiffer(t *testing.T) { + repo := &Repository{} + svc, err := NewHistoryService(repo) + require.NoError(t, err) + + exporter, err := NewExportService(svc, nil) + require.NoError(t, err) + + reconciler, err := NewReconcileService(svc, exporter, nil) + require.NoError(t, err) + assert.NotNil(t, reconciler) +} + +// --- diffPlanToDriftResult tests --- +// These tests verify the core conversion logic without needing a DB. + +func TestDiffPlanToDriftItems_NoDrift(t *testing.T) { + plan := &differ.DiffPlan{ + Actions: []differ.PlannedAction{ + {ResourceType: differ.ResourceInstrument, ResourceCode: "GBP", Action: differ.ActionNoChange}, + {ResourceType: differ.ResourceAccountType, ResourceCode: "CURRENT", Action: differ.ActionNoChange}, + }, + } + + result := diffPlanToReconcileResult(plan, "1.0") + + assert.Empty(t, result.DriftItems) + assert.Equal(t, 0, result.Summary.TotalDrifted) + assert.Equal(t, 2, result.Summary.TotalChecked) + assert.Equal(t, "1.0", result.ReconciledVersion) +} + +func TestDiffPlanToDriftItems_MissingResource(t *testing.T) { + // DELETE in differ means: in stored (old) but not in live (new) -> MISSING. + plan := &differ.DiffPlan{ + Actions: []differ.PlannedAction{ + {ResourceType: differ.ResourceInstrument, ResourceCode: "GBP", Action: differ.ActionDelete, Description: "Delete instrument GBP"}, + {ResourceType: differ.ResourceInstrument, ResourceCode: "KWH", Action: differ.ActionNoChange}, + }, + } + + result := diffPlanToReconcileResult(plan, "1.0") + + require.Len(t, result.DriftItems, 1) + assert.Equal(t, DriftTypeMissing, result.DriftItems[0].DriftType) + assert.Equal(t, "GBP", result.DriftItems[0].ResourceCode) + assert.Equal(t, "instrument", result.DriftItems[0].ResourceType) + assert.Equal(t, 1, result.Summary.Missing) + assert.Equal(t, 1, result.Summary.TotalDrifted) + assert.Equal(t, 2, result.Summary.TotalChecked) +} + +func TestDiffPlanToDriftItems_ExtraResource(t *testing.T) { + // CREATE in differ means: in live (new) but not in stored (old) -> EXTRA. + plan := &differ.DiffPlan{ + Actions: []differ.PlannedAction{ + {ResourceType: differ.ResourceInstrument, ResourceCode: "EUR", Action: differ.ActionCreate, Description: "Create instrument EUR"}, + }, + } + + result := diffPlanToReconcileResult(plan, "1.0") + + require.Len(t, result.DriftItems, 1) + assert.Equal(t, DriftTypeExtra, result.DriftItems[0].DriftType) + assert.Equal(t, "EUR", result.DriftItems[0].ResourceCode) + assert.Equal(t, 1, result.Summary.Extra) + assert.Equal(t, 1, result.Summary.TotalDrifted) +} + +func TestDiffPlanToDriftItems_ModifiedResource(t *testing.T) { + // UPDATE in differ means: both exist but differ -> MODIFIED. + plan := &differ.DiffPlan{ + Actions: []differ.PlannedAction{ + { + ResourceType: differ.ResourceInstrument, + ResourceCode: "GBP", + Action: differ.ActionUpdate, + Description: "Update instrument GBP (name: \"Pound\" -> \"Sterling\")", + }, + }, + } + + result := diffPlanToReconcileResult(plan, "1.0") + + require.Len(t, result.DriftItems, 1) + assert.Equal(t, DriftTypeModified, result.DriftItems[0].DriftType) + assert.Equal(t, "GBP", result.DriftItems[0].ResourceCode) + assert.Contains(t, result.DriftItems[0].Description, "Update instrument GBP") + assert.Equal(t, 1, result.Summary.Modified) +} + +func TestDiffPlanToDriftItems_MixedDrift(t *testing.T) { + plan := &differ.DiffPlan{ + Actions: []differ.PlannedAction{ + {ResourceType: differ.ResourceInstrument, ResourceCode: "GBP", Action: differ.ActionNoChange}, + {ResourceType: differ.ResourceInstrument, ResourceCode: "KWH", Action: differ.ActionDelete}, + {ResourceType: differ.ResourceInstrument, ResourceCode: "EUR", Action: differ.ActionCreate}, + {ResourceType: differ.ResourceSaga, ResourceCode: "settle", Action: differ.ActionUpdate, Description: "script changed"}, + }, + } + + result := diffPlanToReconcileResult(plan, "2.0") + + assert.Equal(t, 4, result.Summary.TotalChecked) + assert.Equal(t, 3, result.Summary.TotalDrifted) + assert.Equal(t, 1, result.Summary.Missing) + assert.Equal(t, 1, result.Summary.Extra) + assert.Equal(t, 1, result.Summary.Modified) + assert.Equal(t, "2.0", result.ReconciledVersion) +} + +// --- DriftItem to Proto conversion tests --- + +func TestToDriftTypeProto(t *testing.T) { + tests := []struct { + input DriftItemType + expected controlplanev1.DriftType + }{ + {DriftTypeMissing, controlplanev1.DriftType_DRIFT_TYPE_MISSING}, + {DriftTypeModified, controlplanev1.DriftType_DRIFT_TYPE_MODIFIED}, + {DriftTypeExtra, controlplanev1.DriftType_DRIFT_TYPE_EXTRA}, + {"unknown", controlplanev1.DriftType_DRIFT_TYPE_UNSPECIFIED}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, toDriftTypeProto(tt.input)) + } +} + +func TestReconcileResult_ToProtoResponse(t *testing.T) { + reconciledAt := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC) + result := &ReconcileResult{ + DriftItems: []DriftItem{ + { + ResourceType: "instrument", + ResourceCode: "GBP", + DriftType: DriftTypeMissing, + Description: "Missing in live state", + }, + { + ResourceType: "saga", + ResourceCode: "settle", + DriftType: DriftTypeExtra, + Description: "Extra in live state", + }, + }, + Summary: ReconcileSummary{ + TotalChecked: 10, + TotalDrifted: 2, + Missing: 1, + Extra: 1, + }, + ReconciledVersion: "1.0", + ReconciledAt: reconciledAt, + Warnings: []string{"warn1"}, + } + + resp := result.ToProtoResponse() + require.NotNil(t, resp) + + assert.Equal(t, "1.0", resp.ReconciledVersion) + require.NotNil(t, resp.ReconciledAt) + assert.Equal(t, reconciledAt.Unix(), resp.ReconciledAt.AsTime().Unix()) + assert.Len(t, resp.DriftItems, 2) + assert.Equal(t, controlplanev1.DriftType_DRIFT_TYPE_MISSING, resp.DriftItems[0].DriftType) + assert.Equal(t, "GBP", resp.DriftItems[0].ResourceCode) + assert.Equal(t, controlplanev1.DriftType_DRIFT_TYPE_EXTRA, resp.DriftItems[1].DriftType) + + require.NotNil(t, resp.Summary) + assert.Equal(t, int32(10), resp.Summary.TotalChecked) + assert.Equal(t, int32(2), resp.Summary.TotalDrifted) + assert.Equal(t, int32(1), resp.Summary.Missing) + assert.Equal(t, int32(1), resp.Summary.Extra) + assert.Equal(t, int32(0), resp.Summary.Modified) + + assert.Equal(t, []string{"warn1"}, resp.Warnings) +} + +func TestReconcileResult_ToProtoResponse_NoDrift(t *testing.T) { + result := &ReconcileResult{ + Summary: ReconcileSummary{ + TotalChecked: 5, + }, + ReconciledVersion: "1.0", + } + + resp := result.ToProtoResponse() + assert.Empty(t, resp.DriftItems) + assert.Equal(t, int32(5), resp.Summary.TotalChecked) + assert.Equal(t, int32(0), resp.Summary.TotalDrifted) +} + +// --- gRPC handler tests --- + +func TestReconcileManifest_NilReconciler_ReturnsUnimplemented(t *testing.T) { + repo := &Repository{} + svc, err := NewHistoryService(repo) + require.NoError(t, err) + + handler, err := NewHistoryHandler(svc, nil) + require.NoError(t, err) + + _, err = handler.ReconcileManifest(context.Background(), &controlplanev1.ReconcileManifestRequest{}) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unimplemented, st.Code()) +} + +func TestNewHistoryHandlerWithReconcile(t *testing.T) { + repo := &Repository{} + svc, err := NewHistoryService(repo) + require.NoError(t, err) + + exporter, err := NewExportService(svc, nil) + require.NoError(t, err) + + reconciler, err := NewReconcileService(svc, exporter, nil) + require.NoError(t, err) + + handler, err := NewHistoryHandlerWithReconcile(svc, exporter, reconciler, nil) + require.NoError(t, err) + assert.NotNil(t, handler) + assert.NotNil(t, handler.reconciler) + assert.NotNil(t, handler.exporter) +} + +func TestNewHistoryHandlerWithReconcile_NilHistory(t *testing.T) { + _, err := NewHistoryHandlerWithReconcile(nil, nil, nil, nil) + assert.ErrorIs(t, err, ErrHistoryServiceRequired) +} + +// --- filterManifestSections tests --- + +func TestFilterManifestSections_OnlyInstruments(t *testing.T) { + m := testManifest("1.0") + + filtered := filterManifestSections(m, []string{"instruments"}) + + assert.NotNil(t, filtered.Instruments) + assert.Nil(t, filtered.AccountTypes) + assert.Nil(t, filtered.Sagas) + assert.Nil(t, filtered.ValuationRules) +} + +func TestFilterManifestSections_InvalidSection_ReturnsNothing(t *testing.T) { + m := testManifest("1.0") + + filtered := filterManifestSections(m, []string{"nonexistent"}) + + assert.Nil(t, filtered.Instruments) + assert.Nil(t, filtered.AccountTypes) +} + +func TestFilterManifestSections_MultipleSections(t *testing.T) { + m := testManifest("1.0") + + filtered := filterManifestSections(m, []string{"instruments", "sagas"}) + + assert.NotNil(t, filtered.Instruments) + assert.NotNil(t, filtered.Sagas) + assert.Nil(t, filtered.AccountTypes) +} + +func TestFilterManifestSections_PreservesVersionAndMetadata(t *testing.T) { + m := testManifest("1.0") + + filtered := filterManifestSections(m, []string{"instruments"}) + + assert.Equal(t, m.Version, filtered.Version) + assert.Equal(t, m.Metadata, filtered.Metadata) +} + +// --- End-to-end-ish reconcile test using the differ directly --- + +func TestReconcile_EndToEnd_NoDrift(t *testing.T) { + // Build stored and live manifests that are identical. + stored := testManifest("1.0") + live := testManifest("1.0") + + d := differ.New(nil, nil) + plan, err := d.Diff(context.Background(), stored, live, differ.WithSkipSafetyChecks()) + require.NoError(t, err) + + result := diffPlanToReconcileResult(plan, "1.0") + + assert.Empty(t, result.DriftItems) + assert.Equal(t, 0, result.Summary.TotalDrifted) + assert.Greater(t, result.Summary.TotalChecked, 0) +} + +func TestReconcile_EndToEnd_MissingInstrument(t *testing.T) { + // Stored has 2 instruments, live has 1. + stored := testManifest("1.0") + live := testManifest("1.0") + live.Instruments = live.Instruments[:1] // Remove KWH + + d := differ.New(nil, nil) + plan, err := d.Diff(context.Background(), stored, live, differ.WithSkipSafetyChecks()) + require.NoError(t, err) + + result := diffPlanToReconcileResult(plan, "1.0") + + assert.Equal(t, 1, result.Summary.Missing) + foundMissing := false + for _, item := range result.DriftItems { + if item.ResourceCode == "KWH" && item.DriftType == DriftTypeMissing { + foundMissing = true + } + } + assert.True(t, foundMissing, "expected KWH to be reported as MISSING") +} + +func TestReconcile_EndToEnd_ExtraInstrument(t *testing.T) { + // Live has an instrument that stored doesn't. + stored := testManifest("1.0") + live := testManifest("1.0") + live.Instruments = append(live.Instruments, &controlplanev1.InstrumentDefinition{ + Code: "EUR", + Name: "Euro", + Type: controlplanev1.InstrumentType_INSTRUMENT_TYPE_FIAT, + }) + + d := differ.New(nil, nil) + plan, err := d.Diff(context.Background(), stored, live, differ.WithSkipSafetyChecks()) + require.NoError(t, err) + + result := diffPlanToReconcileResult(plan, "1.0") + + assert.Equal(t, 1, result.Summary.Extra) + foundExtra := false + for _, item := range result.DriftItems { + if item.ResourceCode == "EUR" && item.DriftType == DriftTypeExtra { + foundExtra = true + } + } + assert.True(t, foundExtra, "expected EUR to be reported as EXTRA") +} + +func TestReconcile_EndToEnd_ModifiedInstrument(t *testing.T) { + stored := testManifest("1.0") + live := testManifest("1.0") + // Modify the name of GBP in live. + live.Instruments[0] = &controlplanev1.InstrumentDefinition{ + Code: "GBP", + Name: "Pound Sterling", // Different name + Type: controlplanev1.InstrumentType_INSTRUMENT_TYPE_FIAT, + Dimensions: &controlplanev1.InstrumentDimensions{ + Unit: "GBP", + Precision: 2, + }, + } + + d := differ.New(nil, nil) + plan, err := d.Diff(context.Background(), stored, live, differ.WithSkipSafetyChecks()) + require.NoError(t, err) + + result := diffPlanToReconcileResult(plan, "1.0") + + assert.Equal(t, 1, result.Summary.Modified) + foundModified := false + for _, item := range result.DriftItems { + if item.ResourceCode == "GBP" && item.DriftType == DriftTypeModified { + foundModified = true + } + } + assert.True(t, foundModified, "expected GBP to be reported as MODIFIED") +} diff --git a/services/control-plane/service/manifest_history.go b/services/control-plane/service/manifest_history.go index a94456810..3e21278b3 100644 --- a/services/control-plane/service/manifest_history.go +++ b/services/control-plane/service/manifest_history.go @@ -51,7 +51,11 @@ func RegisterManifestHistoryService(server *grpc.Server, cfg ManifestHistoryServ if exportErr != nil { return fmt.Errorf("manifest export service: %w", exportErr) } - handler, err = manifest.NewHistoryHandlerWithExport(historySvc, exporter, cfg.Logger) + reconciler, reconcileErr := manifest.NewReconcileService(historySvc, exporter, nil) + if reconcileErr != nil { + return fmt.Errorf("manifest reconcile service: %w", reconcileErr) + } + handler, err = manifest.NewHistoryHandlerWithReconcile(historySvc, exporter, reconciler, cfg.Logger) } else { handler, err = manifest.NewHistoryHandler(historySvc, cfg.Logger) }