diff --git a/cmd/export.go b/cmd/export.go new file mode 100644 index 000000000..fe27a4b81 --- /dev/null +++ b/cmd/export.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "fmt" + "sort" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/raystack/frontier/internal/reconcile" + cli "github.com/spf13/cobra" +) + +func ExportCommand(cliConfig *Config) *cli.Command { + var header string + cmd := &cli.Command{ + Use: "export ", + Short: "Export the current state of a kind as a desired-state file", + Long: heredoc.Doc(` + Read the current state of one kind from the server and print it as a + desired-state YAML document, the format "frontier reconcile" reads. Use it + to write the first version of an environment's file: reconciling the + output changes nothing. + `), + Example: heredoc.Doc(` + $ frontier export platformuser -H "Authorization:Basic " + $ frontier export platformuser -H "Authorization:Basic " > platform-users.yaml + `), + Annotations: map[string]string{ + "group": "core", + "client": "true", + }, + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + adminClient, err := createAdminClient(cliConfig.Host) + if err != nil { + return err + } + registry := reconcileRegistry(adminClient, header) + kind, err := resolveKind(args[0], registry) + if err != nil { + return err + } + out, err := reconcile.Export(cmd.Context(), registry, kind) + if err != nil { + return err + } + // cobra's cmd.Print falls back to stderr; the document must go to + // stdout so redirecting to a file works. + _, err = fmt.Fprint(cmd.OutOrStdout(), string(out)) + return err + }, + } + cmd.Flags().StringVarP(&header, "header", "H", "", "Header : for auth, e.g. 'Authorization:Basic '") + bindFlagsFromClientConfig(cmd) + return cmd +} + +// resolveKind matches the kind argument against the registry, case-insensitive +// and accepting a trailing "s", so "platformusers" finds "PlatformUser". +func resolveKind(arg string, registry map[string]reconcile.Reconciler) (string, error) { + names := make([]string, 0, len(registry)) + for kind := range registry { + if strings.EqualFold(arg, kind) || strings.EqualFold(arg, kind+"s") { + return kind, nil + } + names = append(names, kind) + } + sort.Strings(names) + return "", fmt.Errorf("unknown kind %q (available: %s)", arg, strings.Join(names, ", ")) +} diff --git a/cmd/export_test.go b/cmd/export_test.go new file mode 100644 index 000000000..ef996b5c2 --- /dev/null +++ b/cmd/export_test.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "testing" + + "github.com/raystack/frontier/internal/reconcile" + "github.com/stretchr/testify/assert" +) + +func TestResolveKind(t *testing.T) { + registry := map[string]reconcile.Reconciler{reconcile.KindPlatformUser: nil} + + for _, arg := range []string{"PlatformUser", "platformuser", "PLATFORMUSERS", "platformusers"} { + kind, err := resolveKind(arg, registry) + assert.NoError(t, err, arg) + assert.Equal(t, reconcile.KindPlatformUser, kind, arg) + } + + _, err := resolveKind("nope", registry) + assert.ErrorContains(t, err, `unknown kind "nope" (available: PlatformUser)`) +} diff --git a/cmd/reconcile.go b/cmd/reconcile.go index 49c49348a..9fbd63200 100644 --- a/cmd/reconcile.go +++ b/cmd/reconcile.go @@ -6,6 +6,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/raystack/frontier/internal/reconcile" + "github.com/raystack/frontier/proto/v1beta1/frontierv1beta1connect" cli "github.com/spf13/cobra" ) @@ -24,6 +25,8 @@ func ReconcileCommand(cliConfig *Config) *cli.Command { Supports the PlatformUser kind (platform admins and members) for now. The file decides who has access: anyone listed is added, anyone not listed is removed. Log in as a superuser (for example the bootstrap service account) with --header. + + Use "frontier export " to print the current state in this file format. `), Example: heredoc.Doc(` $ frontier reconcile -f platform-users.yaml --dry-run -H "Authorization:Basic " @@ -42,10 +45,7 @@ func ReconcileCommand(cliConfig *Config) *cli.Command { if err != nil { return err } - registry := map[string]reconcile.Reconciler{ - reconcile.KindPlatformUser: reconcile.NewPlatformUserReconciler(adminClient, header), - } - reports, runErr := reconcile.Run(cmd.Context(), registry, data, dryRun) + reports, runErr := reconcile.Run(cmd.Context(), reconcileRegistry(adminClient, header), data, dryRun) for _, rep := range reports { printReconcileReport(cmd, rep) } @@ -60,17 +60,31 @@ func ReconcileCommand(cliConfig *Config) *cli.Command { return cmd } +// reconcileRegistry holds every reconcilable kind. New kinds register here. +func reconcileRegistry(adminClient frontierv1beta1connect.AdminServiceClient, header string) map[string]reconcile.Reconciler { + return map[string]reconcile.Reconciler{ + reconcile.KindPlatformUser: reconcile.NewPlatformUserReconciler(adminClient, header), + } +} + +// printReconcileReport writes the report to stdout. cobra's cmd.Printf falls +// back to stderr, which breaks piping and redirecting the plan. func printReconcileReport(cmd *cli.Command, rep reconcile.Report) { + // a failed document yields a zero-value report; there is nothing to print + if rep.Kind == "" { + return + } + out := cmd.OutOrStdout() if len(rep.Planned) == 0 { - cmd.Printf("%s: no changes\n", rep.Kind) + fmt.Fprintf(out, "%s: no changes\n", rep.Kind) return } verb, count := "applied", rep.Applied if rep.DryRun { verb, count = "planned", len(rep.Planned) } - cmd.Printf("%s (%s %d):\n", rep.Kind, verb, count) + fmt.Fprintf(out, "%s (%s %d):\n", rep.Kind, verb, count) for _, p := range rep.Planned { - cmd.Printf(" - %s\n", p) + fmt.Fprintf(out, " - %s\n", p) } } diff --git a/cmd/root.go b/cmd/root.go index 552b811a3..354c9526f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -50,6 +50,7 @@ func New(cliConfig *Config) *cli.Command { cmd.AddCommand(PolicyCommand(cliConfig)) cmd.AddCommand(SeedCommand(cliConfig)) cmd.AddCommand(ReconcileCommand(cliConfig)) + cmd.AddCommand(ExportCommand(cliConfig)) cmd.AddCommand(configCommand()) cmd.AddCommand(versionCommand()) cmd.AddCommand(PreferencesCommand(cliConfig)) diff --git a/docs/content/docs/reconcile.mdx b/docs/content/docs/reconcile.mdx new file mode 100644 index 000000000..038289095 --- /dev/null +++ b/docs/content/docs/reconcile.mdx @@ -0,0 +1,122 @@ +--- +title: Reconcile +order: 15 +--- + +# Reconcile + +Frontier can manage parts of its platform configuration from a YAML file instead of +one-off API calls. You write down what should exist, and the `frontier reconcile` command +makes the server match it. The `frontier export` command does the reverse: it prints what +the server has, in the same file format. + +This gives you three things the API alone does not: + +- A reviewable file that says what should exist. Keep it in git and change it via pull + requests. +- A plan before every change. A dry run prints every add, update, and delete the file + would cause, without applying anything. +- Removal that works. Anything the file no longer wants shows up in the plan and is + removed on apply. + +## The desired-state file + +A file holds one or more YAML documents. Each document names the format version, a kind +of resource, and a spec: + +```yaml +apiVersion: v1 +kind: PlatformUser +spec: + - type: user + ref: alice@example.org + relation: admin + - type: user + ref: bob@example.org + relation: member + - type: serviceuser + ref: 9d776a1c-2f2e-4e56-a6f9-71a25a2eab5f + relation: admin +``` + +Rules that apply to every document: + +- A document without `apiVersion` is read as `v1`. An unknown version is rejected. +- A document with a missing spec is rejected, because that is usually a typo. To mean an + empty list on purpose, write `spec: []`. +- The whole file is parsed and checked before anything applies. A malformed later + document stops the run before any change is made. +- Documents apply in the order they appear in the file. + +## The PlatformUser kind + +`PlatformUser` manages who holds platform-wide access: `admin` (superuser) or `member`. +An entry is: + +| Field | Value | +|---|---| +| `type` | `user` or `serviceuser` | +| `ref` | email or id for a user; id for a service user | +| `relation` | `admin` or `member` | + +The file is the full access list. Anyone listed gets that access. Anyone on the server +but not in the file loses it. To add access, add an entry; to remove it, delete the +entry. Someone who holds both relations has two entries. + +Two safety properties: + +- Adds run before removes, so changing someone from `admin` to `member` never leaves + them with no access if a step fails in between. +- The bootstrap service user (the automation account from `app.admin.bootstrap`) is + never touched: the reconciler skips it on the server side and rejects file entries + that name it. + +Adding a user by an email that does not exist creates that user. + +## Running it + +Log in as a superuser. The bootstrap service user exists for exactly this; its client id +and secret make a Basic token: + +```bash +BASIC=$(printf '%s:%s' "$CLIENT_ID" "$CLIENT_SECRET" | base64 | tr -d '\n') +``` + +Always dry-run first and read the plan: + +```bash +$ frontier reconcile -f platform-users.yaml --dry-run \ + --host -H "Authorization:Basic ${BASIC}" +PlatformUser (planned 2): + - add user alice@example.org as admin + - remove user 5f7b...9c1d (member) +``` + +Then apply by running the same command without `--dry-run`. The report shows what was +applied; a run with nothing to do prints `PlatformUser: no changes`. + +The `-H` flag is an interim way to pass the token: command arguments are visible in +process listings, so automation should mask the token in its logs. + +## Exporting the current state + +`frontier export ` prints the live state as a desired-state document on stdout: + +```bash +frontier export platformuser --host -H "Authorization:Basic ${BASIC}" > platform-users.yaml +``` + +Use it to write the first version of a file from a running server. The output is sorted, +so exporting twice gives identical files, and it round-trips: reconciling an export's +output always plans no changes. A clean dry run is proof that a file matches its server. + +The kind argument is case-insensitive and accepts a plural, so `platformuser` and +`PlatformUsers` both work. + +## More kinds + +The design and the rules every kind follows live in +[RFC 0001](https://github.com/raystack/frontier/blob/main/docs/rfcs/0001-declarative-reconcile.md). +Custom permissions and platform-level roles are proposed there as the next kinds; this +page grows with them. The flag reference for both commands is in the +[CLI reference](./reference/cli.md). diff --git a/docs/content/docs/reference/cli.mdx b/docs/content/docs/reference/cli.mdx index e229ac551..533c12f20 100644 --- a/docs/content/docs/reference/cli.mdx +++ b/docs/content/docs/reference/cli.mdx @@ -29,6 +29,21 @@ List client configuration settings List of supported environment variables +## `frontier export ` + +Export the current state of a kind as a desired-state YAML file, printed to +stdout. The output is the format `frontier reconcile` reads: reconciling it +changes nothing. Supported kind: `PlatformUser`. See the +[Reconcile guide](../reconcile.md) for the file format and the flow. + +``` +$ frontier export platformuser -H "Authorization:Basic " > platform-users.yaml +``` + +``` +-H, --header string Header : +```` + ## `frontier group` Manage groups @@ -228,6 +243,24 @@ View a project -m, --metadata Set this flag to see metadata ```` +## `frontier reconcile [flags]` + +Make platform resources match a desired-state YAML file, through the admin +API. The file decides who has access: anyone listed is added, anyone not +listed is removed. Supported kind: `PlatformUser`. Use `frontier export` to +print the current state in this file format, and see the +[Reconcile guide](../reconcile.md) for the full flow. + +``` +$ frontier reconcile -f platform-users.yaml --dry-run -H "Authorization:Basic " +``` + +``` + --dry-run Print the plan without applying changes +-f, --file string Path to the desired-state YAML file +-H, --header string Header : +```` + ## `frontier role` Manage roles diff --git a/internal/reconcile/platformuser_reconciler.go b/internal/reconcile/platformuser_reconciler.go index df9f043da..5723a431a 100644 --- a/internal/reconcile/platformuser_reconciler.go +++ b/internal/reconcile/platformuser_reconciler.go @@ -3,6 +3,7 @@ package reconcile import ( "context" "fmt" + "sort" "strings" "connectrpc.com/connect" @@ -64,6 +65,39 @@ func (r *PlatformUserReconciler) Reconcile(ctx context.Context, spec []byte, dry return rep, nil } +// Export returns the current platform users as a desired-state spec: one entry +// per (principal, relation), users referenced by email when they have one. +// Entries are sorted so repeated exports produce identical files. +func (r *PlatformUserReconciler) Export(ctx context.Context) (any, error) { + current, err := r.fetchCurrent(ctx) + if err != nil { + return nil, err + } + specs := make([]PlatformUserSpec, 0, len(current)) + for _, p := range current { + ref := p.ID + if p.Type == principalTypeUser && p.Email != "" { + ref = p.Email + } + for _, rel := range platformRelationOrder { + if _, ok := p.Relations[rel]; ok { + specs = append(specs, PlatformUserSpec{Type: p.Type, Ref: ref, Relation: rel}) + } + } + } + sort.Slice(specs, func(i, j int) bool { + a, b := specs[i], specs[j] + if a.Type != b.Type { + return a.Type < b.Type + } + if a.Ref != b.Ref { + return a.Ref < b.Ref + } + return a.Relation < b.Relation + }) + return specs, nil +} + func (r *PlatformUserReconciler) fetchCurrent(ctx context.Context) ([]platformPrincipal, error) { resp, err := r.client.ListPlatformUsers(ctx, authReq(&frontierv1beta1.ListPlatformUsersRequest{}, r.header)) if err != nil { diff --git a/internal/reconcile/platformuser_reconciler_test.go b/internal/reconcile/platformuser_reconciler_test.go index 8bf4f8634..819bb1f13 100644 --- a/internal/reconcile/platformuser_reconciler_test.go +++ b/internal/reconcile/platformuser_reconciler_test.go @@ -131,6 +131,70 @@ func TestPlatformUserReconciler_Reconcile(t *testing.T) { assert.Empty(t, api.removed) }) + t.Run("export lists every principal-relation pair, sorted, without the bootstrap SA", func(t *testing.T) { + // unsorted input, a user without an email (falls back to id), a user and a + // service user that each hold both relations (one entry per relation), and + // the bootstrap SA (must be skipped). + api := &fakePlatformUserAPI{ + users: []*frontierv1beta1.User{ + platformUserPBRelations(t, "zoe-id", "zoe@x.com", schema.MemberRelationName), + platformUserPBRelations(t, "noemail-id", "", schema.AdminRelationName), + platformUserPBRelations(t, "alice-id", "alice@x.com", schema.MemberRelationName, schema.AdminRelationName), + }, + sus: []*frontierv1beta1.ServiceUser{ + serviceUserPBRelations(t, schema.BootstrapServiceUserID, schema.AdminRelationName), + serviceUserPBRelations(t, "sa-id", schema.MemberRelationName, schema.AdminRelationName), + }, + } + spec, err := NewPlatformUserReconciler(api, "").Export(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, []PlatformUserSpec{ + {Type: "serviceuser", Ref: "sa-id", Relation: schema.AdminRelationName}, + {Type: "serviceuser", Ref: "sa-id", Relation: schema.MemberRelationName}, + {Type: "user", Ref: "alice@x.com", Relation: schema.AdminRelationName}, + {Type: "user", Ref: "alice@x.com", Relation: schema.MemberRelationName}, + {Type: "user", Ref: "noemail-id", Relation: schema.AdminRelationName}, + {Type: "user", Ref: "zoe@x.com", Relation: schema.MemberRelationName}, + }, spec) + }) + + t.Run("reconciling an exported document plans no changes", func(t *testing.T) { + api := &fakePlatformUserAPI{ + users: []*frontierv1beta1.User{ + platformUserPBRelations(t, "alice-id", "alice@x.com", schema.AdminRelationName, schema.MemberRelationName), + platformUserPBRelations(t, "noemail-id", "", schema.MemberRelationName), + }, + sus: []*frontierv1beta1.ServiceUser{ + serviceUserPBRelations(t, "sa-id", schema.MemberRelationName, schema.AdminRelationName), + }, + } + registry := map[string]Reconciler{KindPlatformUser: NewPlatformUserReconciler(api, "")} + + out, err := Export(context.Background(), registry, KindPlatformUser) + assert.NoError(t, err) + + reports, err := Run(context.Background(), registry, out, true) + assert.NoError(t, err) + if assert.Len(t, reports, 1) { + assert.Empty(t, reports[0].Planned) + } + }) + + t.Run("export of an empty platform yields a document Run accepts", func(t *testing.T) { + registry := map[string]Reconciler{KindPlatformUser: NewPlatformUserReconciler(&fakePlatformUserAPI{}, "")} + + out, err := Export(context.Background(), registry, KindPlatformUser) + assert.NoError(t, err) + assert.Contains(t, string(out), "spec: []") + + reports, err := Run(context.Background(), registry, out, true) + assert.NoError(t, err) + if assert.Len(t, reports, 1) { + assert.Empty(t, reports[0].Planned) + } + }) + t.Run("strips the extra relation when the principal holds both (relations list)", func(t *testing.T) { // alice is desired as admin only but currently holds admin + member; // the reconciler must read both relations and revoke member exactly. diff --git a/internal/reconcile/reconcile.go b/internal/reconcile/reconcile.go index d5a92bed4..990626d1b 100644 --- a/internal/reconcile/reconcile.go +++ b/internal/reconcile/reconcile.go @@ -15,9 +15,14 @@ import ( ) // Reconciler makes a single resource kind match its desired-state spec. +// Export is the reverse direction and is part of the contract: it reads the +// current server state and returns it as a spec value, ready to be marshalled +// into a desired-state document. Reconciling an exported document must plan +// no changes. type Reconciler interface { Kind() string Reconcile(ctx context.Context, spec []byte, dryRun bool) (Report, error) + Export(ctx context.Context) (spec any, err error) } // Report summarises what a reconcile did, or would do when dryRun. @@ -28,17 +33,29 @@ type Report struct { Applied int // number actually applied (0 when dryRun) } +// apiVersion of the document format. A document with no apiVersion is read as +// v1, so files written before the field existed keep working. +const apiVersion = "v1" + type document struct { - Kind string `yaml:"kind"` - Spec yaml.Node `yaml:"spec"` + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Spec yaml.Node `yaml:"spec"` } -// Run dispatches each document in a (possibly multi-document) desired-state file -// to the reconciler for its kind, in file order. The first error stops the run -// and returns the reports gathered so far. -func Run(ctx context.Context, registry map[string]Reconciler, data []byte, dryRun bool) ([]Report, error) { +type parsedDocument struct { + kind string + spec []byte +} + +// parseDocuments reads and checks every document in the file before anything +// runs: the version must be known, the kind registered, and the spec present. +// A missing spec marshals to "null"/"" (usually a typo); it is rejected rather +// than treated as an empty list, which for some kinds means "remove everyone". +// Write `spec: []` to mean that on purpose. +func parseDocuments(registry map[string]Reconciler, data []byte) ([]parsedDocument, error) { dec := yaml.NewDecoder(bytes.NewReader(data)) - var reports []Report + var docs []parsedDocument for { var doc document err := dec.Decode(&doc) @@ -46,31 +63,69 @@ func Run(ctx context.Context, registry map[string]Reconciler, data []byte, dryRu break } if err != nil { - return reports, fmt.Errorf("parse desired-state: %w", err) + return nil, fmt.Errorf("parse desired-state: %w", err) } if doc.Kind == "" { continue } - rec, ok := registry[doc.Kind] - if !ok { - return reports, fmt.Errorf("no reconciler registered for kind %q", doc.Kind) + if doc.APIVersion != "" && doc.APIVersion != apiVersion { + return nil, fmt.Errorf("document kind %q has unsupported apiVersion %q (want %q)", doc.Kind, doc.APIVersion, apiVersion) + } + if _, ok := registry[doc.Kind]; !ok { + return nil, fmt.Errorf("no reconciler registered for kind %q", doc.Kind) } specBytes, err := yaml.Marshal(&doc.Spec) if err != nil { - return reports, fmt.Errorf("marshal spec for kind %q: %w", doc.Kind, err) + return nil, fmt.Errorf("marshal spec for kind %q: %w", doc.Kind, err) } - // A missing spec marshals to "null"/"" (usually a typo). Reject it rather - // than treat it as an empty list that removes everyone; write `spec: []` to - // mean that on purpose. if s := strings.TrimSpace(string(specBytes)); s == "" || s == "null" { - return reports, fmt.Errorf("document kind %q is missing its spec", doc.Kind) + return nil, fmt.Errorf("document kind %q is missing its spec", doc.Kind) } - rep, err := rec.Reconcile(ctx, specBytes, dryRun) + docs = append(docs, parsedDocument{kind: doc.Kind, spec: specBytes}) + } + return docs, nil +} + +// Run applies a (possibly multi-document) desired-state file. The whole file is +// parsed and checked first, so a malformed later document stops the run before +// anything applies. Documents then dispatch in file order — dependency order is +// the file author's job — and the first error stops the run and returns the +// reports gathered so far. +func Run(ctx context.Context, registry map[string]Reconciler, data []byte, dryRun bool) ([]Report, error) { + docs, err := parseDocuments(registry, data) + if err != nil { + return nil, err + } + var reports []Report + for _, doc := range docs { + rep, err := registry[doc.kind].Reconcile(ctx, doc.spec, dryRun) if err != nil { // Return rep too: on a partial apply it shows what was applied. - return append(reports, rep), fmt.Errorf("reconcile %s: %w", doc.Kind, err) + return append(reports, rep), fmt.Errorf("reconcile %s: %w", doc.kind, err) } reports = append(reports, rep) } return reports, nil } + +// Export renders the current server state of one kind as a desired-state YAML +// document that Run accepts as-is. +func Export(ctx context.Context, registry map[string]Reconciler, kind string) ([]byte, error) { + rec, ok := registry[kind] + if !ok { + return nil, fmt.Errorf("no reconciler registered for kind %q", kind) + } + spec, err := rec.Export(ctx) + if err != nil { + return nil, fmt.Errorf("export %s: %w", kind, err) + } + out, err := yaml.Marshal(struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Spec any `yaml:"spec"` + }{APIVersion: apiVersion, Kind: kind, Spec: spec}) + if err != nil { + return nil, fmt.Errorf("marshal %s export: %w", kind, err) + } + return out, nil +} diff --git a/internal/reconcile/reconcile_test.go b/internal/reconcile/reconcile_test.go index b258bcabe..0a132aba8 100644 --- a/internal/reconcile/reconcile_test.go +++ b/internal/reconcile/reconcile_test.go @@ -15,6 +15,7 @@ func (f *fakeReconciler) Reconcile(_ context.Context, _ []byte, _ bool) (Report, f.called++ return Report{Kind: KindPlatformUser}, nil } +func (f *fakeReconciler) Export(_ context.Context) (any, error) { return []string{}, nil } // partialReconciler fails after applying some operations, like a real apply that // dies part-way through. @@ -25,6 +26,7 @@ func (partialReconciler) Reconcile(_ context.Context, _ []byte, _ bool) (Report, return Report{Kind: KindPlatformUser, Applied: 2, Planned: []string{"add a", "add b", "add c"}}, errors.New("apply failed on the third op") } +func (partialReconciler) Export(_ context.Context) (any, error) { return []string{}, nil } func TestRun_SpecHandling(t *testing.T) { t.Run("rejects a document missing its spec (not an empty list)", func(t *testing.T) { @@ -44,6 +46,35 @@ func TestRun_SpecHandling(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 1, rec.called) }) + + t.Run("accepts apiVersion v1 and rejects unknown versions", func(t *testing.T) { + rec := &fakeReconciler{} + reg := map[string]Reconciler{KindPlatformUser: rec} + + _, err := Run(context.Background(), reg, []byte("apiVersion: v1\nkind: PlatformUser\nspec: []\n"), false) + assert.NoError(t, err) + assert.Equal(t, 1, rec.called) + + _, err = Run(context.Background(), reg, []byte("apiVersion: v2\nkind: PlatformUser\nspec: []\n"), false) + assert.ErrorContains(t, err, `unsupported apiVersion "v2"`) + }) + + t.Run("a bad later document stops the run before anything applies", func(t *testing.T) { + rec := &fakeReconciler{} + reg := map[string]Reconciler{KindPlatformUser: rec} + data := []byte("kind: PlatformUser\nspec: []\n---\nkind: Unknown\nspec: []\n") + + _, err := Run(context.Background(), reg, data, false) + assert.ErrorContains(t, err, `no reconciler registered for kind "Unknown"`) + assert.Zero(t, rec.called) // the whole file is checked before any dispatch + }) +} + +func TestExport_Errors(t *testing.T) { + t.Run("unknown kind", func(t *testing.T) { + _, err := Export(context.Background(), map[string]Reconciler{}, "Nope") + assert.ErrorContains(t, err, `no reconciler registered for kind "Nope"`) + }) } func TestRun_ReturnsPartialReportOnError(t *testing.T) {