Skip to content
Merged
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
11 changes: 7 additions & 4 deletions cmd/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ func ReconcileCommand(cliConfig *Config) *cli.Command {
Make platform resources match a desired-state YAML file, through the admin API.

Kinds: PlatformUser (platform admins and members), Permission (custom
permissions), and Role (platform-level roles). Deleting a permission or a
custom role needs an explicit 'delete: true' on its entry; nothing is deleted
by omission, and a predefined role cannot be deleted. Log in as a superuser
(for example the bootstrap service account) with --header.
permissions), Role (platform-level roles), and Preference (platform
settings). Deleting a permission or a custom role needs an explicit
'delete: true' on its entry; nothing is deleted by omission, and a predefined
role cannot be deleted. A preference left out of the file resets to its
default. 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.
`),
Expand Down Expand Up @@ -85,6 +87,7 @@ func buildReconcileRegistry(host, header string) (map[string]reconcile.Reconcile
reconcile.KindPlatformUser: reconcile.NewPlatformUserReconciler(adminClient, header),
reconcile.KindPermission: reconcile.NewPermissionReconciler(api, header),
reconcile.KindRole: reconcile.NewRoleReconciler(api, header),
reconcile.KindPreference: reconcile.NewPreferenceReconciler(api, header),
}, nil
}

Expand Down
28 changes: 26 additions & 2 deletions docs/content/docs/reconcile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,30 @@ takes those permissions away on the next apply.
Permission references accept any form the server knows: the slug (`compute_order_get`),
`service/resource:verb`, or `service.resource.verb`.

## The Preference kind

`Preference` manages platform settings, like whether new organizations can be created or
what an invite email says. An entry is a trait name and a value.

```yaml
apiVersion: v1
kind: Preference
spec:
- name: disable_orgs_on_create
value: "true"
- name: invite_with_roles
value: "false"
```

- Values are strings. A yes/no setting is `"true"` or `"false"`.
- The name must be a platform trait the server knows. An unknown name fails the plan.
- This kind resets instead of deleting. The file is the full desired state: a preference
you list is set to your value, and a preference you leave out goes back to its default.
There is no `delete` flag, because setting a preference back to its default is what
removal means here.
- Export writes only the preferences whose value differs from the default, so settings at
their default stay out of the file.

## Running it

Log in as a superuser. The bootstrap service user exists for exactly this; its client id
Expand Down Expand Up @@ -192,8 +216,8 @@ The kind argument is case-insensitive and accepts a plural, so `platformuser` an

## More kinds

This page covers `PlatformUser`, `Permission`, and `Role`. The design and the rules every
kind follows live in
This page covers `PlatformUser`, `Permission`, `Role`, and `Preference`. 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),
which also lists the kinds proposed next. The flag reference for both commands is in the
[CLI reference](./reference/cli.md).
12 changes: 7 additions & 5 deletions docs/content/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ List of supported environment variables

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 kinds: `PlatformUser`, `Permission`, `Role`. See the
changes nothing. Supported kinds: `PlatformUser`, `Permission`, `Role`,
`Preference`. See the
[Reconcile guide](../reconcile.md) for the file format and the flow.

```
Expand Down Expand Up @@ -247,10 +248,11 @@ View a project

Make platform resources match a desired-state YAML file, through the admin
API. Supported kinds: `PlatformUser` (anyone listed is added, anyone not
listed is removed), `Permission` (custom permissions), and `Role`
(platform-level roles). Deleting a permission or a custom role needs an
explicit `delete: true` on its entry; nothing is deleted by omission, and a
predefined role cannot be deleted. Use
listed is removed), `Permission` (custom permissions), `Role`
(platform-level roles), and `Preference` (platform settings, where a setting
left out of the file resets to its default). Deleting a permission or a custom
role needs an explicit `delete: true` on its entry; nothing is deleted by
omission, and a predefined role cannot be deleted. Use
`frontier export` to print the current state in this file format, and see the
[Reconcile guide](../reconcile.md) for the full flow.

Expand Down
95 changes: 95 additions & 0 deletions internal/reconcile/preference.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package reconcile

import (
"fmt"
"sort"
"strings"
)

// KindPreference is the desired-state document kind for platform preferences.
const KindPreference = "Preference"

const (
opSet opAction = "set"
opReset opAction = "reset"
)

// PreferenceSpec is one desired platform preference. Name is a trait name the
// server knows; value is the string value to set. Preferences are strings end
// to end, so a boolean-like trait is "true" or "false".
type PreferenceSpec struct {
Name string `yaml:"name"`
Value string `yaml:"value"`
}

// preferenceOp is a single planned change. For a reset, value is the trait
// default that gets written back, because the API has no delete.
type preferenceOp struct {
action opAction
name string
value string
}

func (o preferenceOp) String() string {
if o.action == opReset {
return fmt.Sprintf("reset preference %s to default", o.name)
}
return fmt.Sprintf("set preference %s = %s", o.name, o.value)
}

// diffPreferences returns the ops that make the current platform preferences
// match the desired spec. The file is the full desired state: a preference the
// file lists is set to its value, and a preference the file leaves out is reset
// to its trait default. defaults holds every valid platform trait and its
// default, so it is both the source of defaults and the set of known names.
func diffPreferences(desired []PreferenceSpec, current, defaults map[string]string) ([]preferenceOp, error) {
desiredByName := make(map[string]string, len(desired))
for _, s := range desired {
if strings.TrimSpace(s.Name) == "" {
return nil, fmt.Errorf("preference name is required")
}
if _, dup := desiredByName[s.Name]; dup {
return nil, fmt.Errorf("preference %q is listed more than once", s.Name)
}
if _, known := defaults[s.Name]; !known {
return nil, fmt.Errorf("unknown platform preference %q", s.Name)
}
desiredByName[s.Name] = s.Value
}

// serverValue is the value in effect on the server: the stored value, or
// the trait default when nothing is stored. An empty stored value counts as
// unset, matching how the server resolves platform preferences, so a file
// entry equal to the default plans no change against it.
serverValue := func(name string) string {
if v, ok := current[name]; ok && v != "" {
return v
}
return defaults[name]
}

var sets, resets []preferenceOp
for name, value := range desiredByName {
if value != serverValue(name) {
sets = append(sets, preferenceOp{action: opSet, name: name, value: value})
}
}
for name, value := range current {
def, known := defaults[name]
if !known {
// A stored value whose trait no longer exists: leave it alone. The
// file cannot name it (unknown names fail), so nothing manages it.
continue
}
if _, listed := desiredByName[name]; listed {
continue
}
if value != def {
resets = append(resets, preferenceOp{action: opReset, name: name, value: def})
}
Comment on lines +71 to +89

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Normalize empty stored values consistently.

The planner recognizes "" as unset/default, but reset planning and export compare the raw value. This causes unnecessary resets and exports that do not round-trip cleanly.

  • internal/reconcile/preference.go#L71-L89: compare serverValue(name) with the default when deciding resets.
  • internal/reconcile/preference_reconciler.go#L85-L92: omit empty stored values from export.
  • internal/reconcile/preference_test.go#L86-L95: test that an omitted empty stored preference produces no operation.
  • internal/reconcile/preference_reconciler_test.go#L108-L137: test that empty stored values are omitted and round-trip without changes.
📍 Affects 4 files
  • internal/reconcile/preference.go#L71-L89 (this comment)
  • internal/reconcile/preference_reconciler.go#L85-L92
  • internal/reconcile/preference_test.go#L86-L95
  • internal/reconcile/preference_reconciler_test.go#L108-L137

}

sort.Slice(sets, func(i, j int) bool { return sets[i].name < sets[j].name })
sort.Slice(resets, func(i, j int) bool { return resets[i].name < resets[j].name })
return append(sets, resets...), nil
}
133 changes: 133 additions & 0 deletions internal/reconcile/preference_reconciler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package reconcile

import (
"context"
"fmt"
"sort"

"connectrpc.com/connect"
"github.com/raystack/frontier/internal/bootstrap/schema"
frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1"
)

// PreferenceAPI is the API subset the preference reconciler needs. The reads
// live on different services (platform preferences on AdminService, the trait
// list on FrontierService); the caller provides one value that serves both.
type PreferenceAPI interface {
ListPreferences(context.Context, *connect.Request[frontierv1beta1.ListPreferencesRequest]) (*connect.Response[frontierv1beta1.ListPreferencesResponse], error)
DescribePreferences(context.Context, *connect.Request[frontierv1beta1.DescribePreferencesRequest]) (*connect.Response[frontierv1beta1.DescribePreferencesResponse], error)
CreatePreferences(context.Context, *connect.Request[frontierv1beta1.CreatePreferencesRequest]) (*connect.Response[frontierv1beta1.CreatePreferencesResponse], error)
}

// PreferenceReconciler makes platform preferences match the desired spec. A
// preference is a name and a value; a missing entry resets to the trait
// default, because settings always have a default to fall back to.
type PreferenceReconciler struct {
client PreferenceAPI
header string
}

func NewPreferenceReconciler(client PreferenceAPI, header string) *PreferenceReconciler {
return &PreferenceReconciler{client: client, header: header}
}

func (r *PreferenceReconciler) Kind() string { return KindPreference }

func (r *PreferenceReconciler) Reconcile(ctx context.Context, spec []byte, dryRun bool) (Report, error) {
var specs []PreferenceSpec
if err := decodeSpec(spec, &specs); err != nil {
return Report{}, fmt.Errorf("parse %s spec: %w", KindPreference, err)
}

current, err := r.fetchCurrent(ctx)
if err != nil {
return Report{}, err
}
defaults, err := r.fetchDefaults(ctx)
if err != nil {
return Report{}, err
}

ops, err := diffPreferences(specs, current, defaults)
if err != nil {
return Report{}, err
}

rep := Report{Kind: KindPreference, DryRun: dryRun}
for _, op := range ops {
rep.Planned = append(rep.Planned, op.String())
}
if dryRun {
return rep, nil
}
for _, op := range ops {
if err := r.apply(ctx, op); err != nil {
return rep, fmt.Errorf("apply [%s]: %w", op, err)
}
rep.Applied++
}
return rep, nil
}

// Export returns the platform preferences whose value differs from the trait
// default, sorted by name. Preferences at their default stay out of the file,
// so reconciling an export plans no changes.
func (r *PreferenceReconciler) Export(ctx context.Context) (any, error) {
current, err := r.fetchCurrent(ctx)
if err != nil {
return nil, err
}
defaults, err := r.fetchDefaults(ctx)
if err != nil {
return nil, err
}

specs := make([]PreferenceSpec, 0, len(current))
for name, value := range current {
def, known := defaults[name]
if !known || value == def {
continue
}
specs = append(specs, PreferenceSpec{Name: name, Value: value})
}
sort.Slice(specs, func(i, j int) bool { return specs[i].Name < specs[j].Name })
return specs, nil
}

// fetchCurrent reads the platform preferences that are stored on the server.
// A trait with no stored value does not appear here; it is at its default.
func (r *PreferenceReconciler) fetchCurrent(ctx context.Context) (map[string]string, error) {
resp, err := r.client.ListPreferences(ctx, authReq(&frontierv1beta1.ListPreferencesRequest{}, r.header))
if err != nil {
return nil, fmt.Errorf("list preferences: %w", err)
}
current := map[string]string{}
for _, p := range resp.Msg.GetPreferences() {
current[p.GetName()] = p.GetValue()
}
return current, nil
}

// fetchDefaults reads the platform traits and their defaults. It is both the
// map of defaults and the set of valid platform preference names.
func (r *PreferenceReconciler) fetchDefaults(ctx context.Context) (map[string]string, error) {
resp, err := r.client.DescribePreferences(ctx, authReq(&frontierv1beta1.DescribePreferencesRequest{}, r.header))
if err != nil {
return nil, fmt.Errorf("describe preferences: %w", err)
}
defaults := map[string]string{}
for _, t := range resp.Msg.GetTraits() {
if t.GetResourceType() != schema.PlatformNamespace {
continue
}
defaults[t.GetName()] = t.GetDefault()
}
return defaults, nil
}

func (r *PreferenceReconciler) apply(ctx context.Context, op preferenceOp) error {
_, err := r.client.CreatePreferences(ctx, authReq(&frontierv1beta1.CreatePreferencesRequest{
Preferences: []*frontierv1beta1.PreferenceRequestBody{{Name: op.name, Value: op.value}},
}, r.header))
return err
}
Loading
Loading