From 12fc9a5076474d53707c9ab9e750188891bcd205 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 3 Jul 2026 08:11:33 +0530 Subject: [PATCH 1/4] feat(cli): add export command to generate desired-state YAML from current state --- cmd/export.go | 76 +++++++++++++++++++ cmd/export_test.go | 21 +++++ cmd/reconcile.go | 15 +++- cmd/root.go | 1 + internal/reconcile/platformuser_reconciler.go | 34 +++++++++ .../reconcile/platformuser_reconciler_test.go | 64 ++++++++++++++++ internal/reconcile/reconcile.go | 32 ++++++++ internal/reconcile/reconcile_test.go | 13 ++++ 8 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 cmd/export.go create mode 100644 cmd/export_test.go diff --git a/cmd/export.go b/cmd/export.go new file mode 100644 index 000000000..64495aa90 --- /dev/null +++ b/cmd/export.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/raystack/frontier/internal/reconcile" + cli "github.com/spf13/cobra" +) + +func ExportCommand(cliConfig *Config) *cli.Command { + var ( + output string + 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 -o platform-users.yaml -H "Authorization:Basic " + `), + 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 + } + if output == "" { + cmd.Print(string(out)) + return nil + } + return os.WriteFile(output, out, 0o644) + }, + } + cmd.Flags().StringVarP(&output, "output", "o", "", "Write to this file instead of stdout") + 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..c9c2e9a29 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,6 +60,13 @@ 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), + } +} + func printReconcileReport(cmd *cli.Command, rep reconcile.Report) { if len(rep.Planned) == 0 { cmd.Printf("%s: no changes\n", rep.Kind) 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/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..0b3c2aec5 100644 --- a/internal/reconcile/reconcile.go +++ b/internal/reconcile/reconcile.go @@ -20,6 +20,13 @@ type Reconciler interface { Reconcile(ctx context.Context, spec []byte, dryRun bool) (Report, error) } +// Exporter is the optional reverse of Reconciler: it reads the current server +// state and returns it as a spec value, ready to be marshalled into a +// desired-state document. Reconciling the exported document must plan no changes. +type Exporter interface { + Export(ctx context.Context) (spec any, err error) +} + // Report summarises what a reconcile did, or would do when dryRun. type Report struct { Kind string @@ -74,3 +81,28 @@ func Run(ctx context.Context, registry map[string]Reconciler, data []byte, dryRu } 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) + } + exp, ok := rec.(Exporter) + if !ok { + return nil, fmt.Errorf("kind %q does not support export", kind) + } + spec, err := exp.Export(ctx) + if err != nil { + return nil, fmt.Errorf("export %s: %w", kind, err) + } + out, err := yaml.Marshal(struct { + Kind string `yaml:"kind"` + Spec any `yaml:"spec"` + }{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..76db2c67f 100644 --- a/internal/reconcile/reconcile_test.go +++ b/internal/reconcile/reconcile_test.go @@ -46,6 +46,19 @@ func TestRun_SpecHandling(t *testing.T) { }) } +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"`) + }) + + t.Run("kind without export support", func(t *testing.T) { + reg := map[string]Reconciler{KindPlatformUser: &fakeReconciler{}} + _, err := Export(context.Background(), reg, KindPlatformUser) + assert.ErrorContains(t, err, `does not support export`) + }) +} + func TestRun_ReturnsPartialReportOnError(t *testing.T) { reg := map[string]Reconciler{KindPlatformUser: partialReconciler{}} From 5502e7c76860f8eea6c02bc43dbd3d4ba6b3f5e4 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 3 Jul 2026 15:56:11 +0530 Subject: [PATCH 2/4] refactor(cli): export prints to stdout only, document reconcile and export in CLI reference --- cmd/export.go | 16 ++++----------- docs/content/docs/reference/cli.mdx | 31 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/cmd/export.go b/cmd/export.go index 64495aa90..a5382300d 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "os" "sort" "strings" @@ -12,10 +11,7 @@ import ( ) func ExportCommand(cliConfig *Config) *cli.Command { - var ( - output string - header string - ) + var header string cmd := &cli.Command{ Use: "export ", Short: "Export the current state of a kind as a desired-state file", @@ -27,7 +23,7 @@ func ExportCommand(cliConfig *Config) *cli.Command { `), Example: heredoc.Doc(` $ frontier export platformuser -H "Authorization:Basic " - $ frontier export platformuser -o platform-users.yaml -H "Authorization:Basic " + $ frontier export platformuser -H "Authorization:Basic " > platform-users.yaml `), Annotations: map[string]string{ "group": "core", @@ -48,14 +44,10 @@ func ExportCommand(cliConfig *Config) *cli.Command { if err != nil { return err } - if output == "" { - cmd.Print(string(out)) - return nil - } - return os.WriteFile(output, out, 0o644) + cmd.Print(string(out)) + return nil }, } - cmd.Flags().StringVarP(&output, "output", "o", "", "Write to this file instead of stdout") cmd.Flags().StringVarP(&header, "header", "H", "", "Header : for auth, e.g. 'Authorization:Basic '") bindFlagsFromClientConfig(cmd) return cmd diff --git a/docs/content/docs/reference/cli.mdx b/docs/content/docs/reference/cli.mdx index e229ac551..779cf9d87 100644 --- a/docs/content/docs/reference/cli.mdx +++ b/docs/content/docs/reference/cli.mdx @@ -29,6 +29,20 @@ 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`. + +``` +$ frontier export platformuser -H "Authorization:Basic " > platform-users.yaml +``` + +``` +-H, --header string Header : +```` + ## `frontier group` Manage groups @@ -228,6 +242,23 @@ 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. + +``` +$ 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 From 62bea17f4a597282286f9648f406ea68f43ffe22 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 3 Jul 2026 20:09:30 +0530 Subject: [PATCH 3/4] fix(cli): write export document and reconcile report to stdout, not stderr --- cmd/export.go | 6 ++++-- cmd/reconcile.go | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/cmd/export.go b/cmd/export.go index a5382300d..fe27a4b81 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -44,8 +44,10 @@ func ExportCommand(cliConfig *Config) *cli.Command { if err != nil { return err } - cmd.Print(string(out)) - return nil + // 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 '") diff --git a/cmd/reconcile.go b/cmd/reconcile.go index c9c2e9a29..10491ee73 100644 --- a/cmd/reconcile.go +++ b/cmd/reconcile.go @@ -67,17 +67,20 @@ func reconcileRegistry(adminClient frontierv1beta1connect.AdminServiceClient, he } } +// 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) { + 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) } } From 4c606c0c7f0730e866de754f33de3542d9984049 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 3 Jul 2026 20:13:19 +0530 Subject: [PATCH 4/4] fix(cli): skip the empty report a failed reconcile document produces --- cmd/reconcile.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/reconcile.go b/cmd/reconcile.go index 10491ee73..9fbd63200 100644 --- a/cmd/reconcile.go +++ b/cmd/reconcile.go @@ -70,6 +70,10 @@ func reconcileRegistry(adminClient frontierv1beta1connect.AdminServiceClient, he // 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 { fmt.Fprintf(out, "%s: no changes\n", rep.Kind)