Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions cmd/export.go
Original file line number Diff line number Diff line change
@@ -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 <kind>",
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 <base64>"
$ frontier export platformuser -H "Authorization:Basic <base64>" > 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 <key>:<value> for auth, e.g. 'Authorization:Basic <base64>'")
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, ", "))
}
21 changes: 21 additions & 0 deletions cmd/export_test.go
Original file line number Diff line number Diff line change
@@ -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)`)
}
28 changes: 21 additions & 7 deletions cmd/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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 <kind>" to print the current state in this file format.
`),
Example: heredoc.Doc(`
$ frontier reconcile -f platform-users.yaml --dry-run -H "Authorization:Basic <base64>"
Expand All @@ -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)
}
Expand All @@ -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)
}
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
31 changes: 31 additions & 0 deletions docs/content/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ List client configuration settings

List of supported environment variables

## `frontier export <kind>`

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 <base64>" > platform-users.yaml
```

```
-H, --header string Header <key>:<value>
````

## `frontier group`

Manage groups
Expand Down Expand Up @@ -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 <base64>"
```

```
--dry-run Print the plan without applying changes
-f, --file string Path to the desired-state YAML file
-H, --header string Header <key>:<value>
````

## `frontier role`

Manage roles
Expand Down
34 changes: 34 additions & 0 deletions internal/reconcile/platformuser_reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package reconcile
import (
"context"
"fmt"
"sort"
"strings"

"connectrpc.com/connect"
Expand Down Expand Up @@ -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 {
Expand Down
64 changes: 64 additions & 0 deletions internal/reconcile/platformuser_reconciler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions internal/reconcile/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
13 changes: 13 additions & 0 deletions internal/reconcile/reconcile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}}

Expand Down
Loading