Skip to content
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
122 changes: 122 additions & 0 deletions docs/content/docs/reconcile.mdx
Original file line number Diff line number Diff line change
@@ -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 <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 <kind>` prints the live state as a desired-state document on stdout:

```bash
frontier export platformuser --host <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).
33 changes: 33 additions & 0 deletions docs/content/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ 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`. See the
[Reconcile guide](../reconcile.md) for the file format and the flow.

```
$ 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 +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 <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
Loading
Loading