From d4fea7fb19f4014b0d734f86c828cd3142b84a3b Mon Sep 17 00:00:00 2001 From: Francisco Meneses Date: Thu, 11 Jun 2026 16:35:22 -0400 Subject: [PATCH] feat(export): record skipped sidecars in manifest warnings Append warnings when optional product Admin API fetches fail, mark export incomplete, and surface the list in the visualizer index. Co-authored-by: Cursor --- internal/export/exporter.go | 5 ++-- internal/export/exporter_test.go | 46 +++++++++++++++++++++++++++++++ internal/output/writer.go | 26 +++++++++++------ internal/output/writer_test.go | 11 ++++++++ internal/visualize/report.go | 7 +++++ internal/visualize/report_test.go | 27 ++++++++++++++++++ 6 files changed, 112 insertions(+), 10 deletions(-) diff --git a/internal/export/exporter.go b/internal/export/exporter.go index 3284a5b..a345cef 100644 --- a/internal/export/exporter.go +++ b/internal/export/exporter.go @@ -101,7 +101,7 @@ func (s *Service) Export(ctx context.Context, opts Options) (*output.Manifest, e } for _, svc := range services { - if err := s.exportService(ctx, writer, opts, svc); err != nil { + if err := s.exportService(ctx, writer, opts, svc, manifest); err != nil { manifest.Incomplete = true return manifest, fmt.Errorf("export service %q: %w", svc.SystemName, err) } @@ -179,7 +179,7 @@ func (s *Service) exportPolicyCatalog(ctx context.Context, writer *output.Writer return writer.WriteJSON("policies/catalog.json", catalog) } -func (s *Service) exportService(ctx context.Context, writer *output.Writer, opts Options, svc serviceRef) error { +func (s *Service) exportService(ctx context.Context, writer *output.Writer, opts Options, svc serviceRef, manifest *output.Manifest) error { yamlData, err := s.toolbox.ExportProduct(ctx, opts.AdminURL, opts.Token, svc.SystemName) if err != nil { return err @@ -203,6 +203,7 @@ func (s *Service) exportService(ctx context.Context, writer *output.Writer, opts for _, f := range fetches { var payload json.RawMessage if err := s.client.Get(ctx, f.path, &payload); err != nil { + manifest.RecordSkip(svc.SystemName, f.file, f.path, err) continue } rel := filepath.Join("products", svc.SystemName, f.file) diff --git a/internal/export/exporter_test.go b/internal/export/exporter_test.go index 544bb45..7743ac6 100644 --- a/internal/export/exporter_test.go +++ b/internal/export/exporter_test.go @@ -230,3 +230,49 @@ func TestAppendYAMLNewline(t *testing.T) { t.Fatalf("got %q", got) } } + +func TestExportRecordsWarningsOnSkippedSidecars(t *testing.T) { + dir := t.TempDir() + client := &mockClient{ + responses: map[string]any{ + "/services": serviceListResponse{ + Services: []serviceEntry{{Service: serviceRef{ID: 10, SystemName: "payments"}}}, + }, + "/backend_apis": backendListResponse{}, + "/policies": map[string]any{}, + "/services/10/proxy": map[string]any{ + "proxy": map[string]any{"auth_type": "oidc"}, + }, + "/services/10/proxy/policies": map[string]any{"policies": []any{}}, + "/services/10/application_plans": map[string]any{"plans": []any{}}, + "/services/10/backend_usages": map[string]any{"backend_usages": []any{}}, + "/services/10/metrics": map[string]any{"metrics": []any{}}, + }, + } + toolbox := &mockToolbox{outputs: map[string][]byte{"payments": []byte("apiVersion: v1\n")}} + + svc := NewService(client, toolbox) + manifest, err := svc.Export(context.Background(), Options{ + AdminURL: "https://tenant.example.com", + Token: "tok", + OutDir: dir, + }) + if err != nil { + t.Fatal(err) + } + if !manifest.Incomplete { + t.Fatal("expected incomplete manifest") + } + if len(manifest.Warnings) != 1 { + t.Fatalf("Warnings = %#v, want 1 entry", manifest.Warnings) + } + if !strings.Contains(manifest.Warnings[0], "oidc_configuration.json") { + t.Fatalf("warning = %q", manifest.Warnings[0]) + } + if _, err := os.Stat(filepath.Join(dir, "products", "payments", "proxy.json")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "products", "payments", "oidc_configuration.json")); !os.IsNotExist(err) { + t.Fatalf("oidc_configuration.json should be absent: %v", err) + } +} diff --git a/internal/output/writer.go b/internal/output/writer.go index 0a4ade5..4e0a3b4 100644 --- a/internal/output/writer.go +++ b/internal/output/writer.go @@ -11,14 +11,24 @@ import ( const SchemaVersion = "1.0" type Manifest struct { - SchemaVersion string `json:"schema_version"` - ExportedAt string `json:"exported_at"` - AdminURL string `json:"admin_url"` - ProductCount int `json:"product_count"` - BackendCount int `json:"backend_count"` - ApplicationCount int `json:"application_count,omitempty"` - IncludeApplications bool `json:"include_applications"` - Incomplete bool `json:"incomplete"` + SchemaVersion string `json:"schema_version"` + ExportedAt string `json:"exported_at"` + AdminURL string `json:"admin_url"` + ProductCount int `json:"product_count"` + BackendCount int `json:"backend_count"` + ApplicationCount int `json:"application_count,omitempty"` + IncludeApplications bool `json:"include_applications"` + Incomplete bool `json:"incomplete"` + Warnings []string `json:"warnings,omitempty"` +} + +// RecordSkip appends a warning when an optional export resource could not be fetched. +func (m *Manifest) RecordSkip(product, file, apiPath string, err error) { + if m == nil || err == nil { + return + } + m.Warnings = append(m.Warnings, fmt.Sprintf("product %s: skipped %s (%s: %v)", product, file, apiPath, err)) + m.Incomplete = true } type Writer struct { diff --git a/internal/output/writer_test.go b/internal/output/writer_test.go index 3aa4df6..2a100f1 100644 --- a/internal/output/writer_test.go +++ b/internal/output/writer_test.go @@ -146,3 +146,14 @@ func TestWriterRoot(t *testing.T) { t.Fatalf("Root = %q", w.Root()) } } + +func TestManifestRecordSkip(t *testing.T) { + m := &Manifest{} + m.RecordSkip("payments", "oidc_configuration.json", "/services/10/proxy/oidc_configuration", os.ErrNotExist) + if !m.Incomplete { + t.Fatal("expected incomplete") + } + if len(m.Warnings) != 1 || !strings.Contains(m.Warnings[0], "payments") { + t.Fatalf("Warnings = %#v", m.Warnings) + } +} diff --git a/internal/visualize/report.go b/internal/visualize/report.go index 43ed2b3..5ee719c 100644 --- a/internal/visualize/report.go +++ b/internal/visualize/report.go @@ -56,6 +56,13 @@ func renderIndex(t *Tenant, outDir string) string { if t.Manifest.Incomplete { b.WriteString("> **Warning:** Export marked incomplete — some data may be missing.\n\n") } + if len(t.Manifest.Warnings) > 0 { + b.WriteString("## Export warnings\n\n") + for _, warning := range t.Manifest.Warnings { + b.WriteString(fmt.Sprintf("- %s\n", mdCell(warning))) + } + b.WriteString("\n") + } b.WriteString("## Overview\n\n") b.WriteString("| Field | Value |\n") diff --git a/internal/visualize/report_test.go b/internal/visualize/report_test.go index 18e9565..701fa6c 100644 --- a/internal/visualize/report_test.go +++ b/internal/visualize/report_test.go @@ -140,6 +140,33 @@ func TestWriteReportIncompleteBanner(t *testing.T) { } } +func TestWriteReportManifestWarnings(t *testing.T) { + tenant, err := LoadExport(filepath.Join("testdata", "export-minimal")) + if err != nil { + t.Fatal(err) + } + tenant.Manifest.Incomplete = true + tenant.Manifest.Warnings = []string{ + "product payments: skipped oidc_configuration.json (/services/10/proxy/oidc_configuration: unrecoverable)", + } + + out := t.TempDir() + if err := WriteReport(tenant, out); err != nil { + t.Fatal(err) + } + index, err := os.ReadFile(filepath.Join(out, "index.md")) + if err != nil { + t.Fatal(err) + } + text := string(index) + if !strings.Contains(text, "## Export warnings") { + t.Fatalf("missing warnings section: %s", text) + } + if !strings.Contains(text, "oidc_configuration.json") { + t.Fatalf("missing warning detail: %s", text) + } +} + func TestWriteReportValidation(t *testing.T) { if err := WriteReport(nil, t.TempDir()); err == nil { t.Fatal("expected error for nil tenant")