diff --git a/cmd/reconcile.go b/cmd/reconcile.go index b711a3a08..8be765855 100644 --- a/cmd/reconcile.go +++ b/cmd/reconcile.go @@ -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 " to print the current state in this file format. `), @@ -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 } diff --git a/docs/content/docs/reconcile.mdx b/docs/content/docs/reconcile.mdx index 4ac74dd5f..6c54302fb 100644 --- a/docs/content/docs/reconcile.mdx +++ b/docs/content/docs/reconcile.mdx @@ -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 @@ -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). diff --git a/docs/content/docs/reference/cli.mdx b/docs/content/docs/reference/cli.mdx index 47a2b39a4..f57048dff 100644 --- a/docs/content/docs/reference/cli.mdx +++ b/docs/content/docs/reference/cli.mdx @@ -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. ``` @@ -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. diff --git a/internal/reconcile/preference.go b/internal/reconcile/preference.go new file mode 100644 index 000000000..9fc79a89c --- /dev/null +++ b/internal/reconcile/preference.go @@ -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}) + } + } + + 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 +} diff --git a/internal/reconcile/preference_reconciler.go b/internal/reconcile/preference_reconciler.go new file mode 100644 index 000000000..1044c2a70 --- /dev/null +++ b/internal/reconcile/preference_reconciler.go @@ -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 +} diff --git a/internal/reconcile/preference_reconciler_test.go b/internal/reconcile/preference_reconciler_test.go new file mode 100644 index 000000000..ef2294758 --- /dev/null +++ b/internal/reconcile/preference_reconciler_test.go @@ -0,0 +1,138 @@ +package reconcile + +import ( + "context" + "testing" + + "connectrpc.com/connect" + "github.com/raystack/frontier/internal/bootstrap/schema" + frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1" + "github.com/stretchr/testify/assert" +) + +type fakePreferenceAPI struct { + prefs []*frontierv1beta1.Preference + traits []*frontierv1beta1.PreferenceTrait + created []*frontierv1beta1.PreferenceRequestBody +} + +func (f *fakePreferenceAPI) ListPreferences(_ context.Context, _ *connect.Request[frontierv1beta1.ListPreferencesRequest]) (*connect.Response[frontierv1beta1.ListPreferencesResponse], error) { + return connect.NewResponse(&frontierv1beta1.ListPreferencesResponse{Preferences: f.prefs}), nil +} + +func (f *fakePreferenceAPI) DescribePreferences(_ context.Context, _ *connect.Request[frontierv1beta1.DescribePreferencesRequest]) (*connect.Response[frontierv1beta1.DescribePreferencesResponse], error) { + return connect.NewResponse(&frontierv1beta1.DescribePreferencesResponse{Traits: f.traits}), nil +} + +func (f *fakePreferenceAPI) CreatePreferences(_ context.Context, req *connect.Request[frontierv1beta1.CreatePreferencesRequest]) (*connect.Response[frontierv1beta1.CreatePreferencesResponse], error) { + f.created = append(f.created, req.Msg.GetPreferences()...) + return connect.NewResponse(&frontierv1beta1.CreatePreferencesResponse{}), nil +} + +func platformTraitPB(name, def string) *frontierv1beta1.PreferenceTrait { + return &frontierv1beta1.PreferenceTrait{Name: name, Default: def, ResourceType: schema.PlatformNamespace} +} + +func prefPB(name, value string) *frontierv1beta1.Preference { + return &frontierv1beta1.Preference{Name: name, Value: value} +} + +func platformTraits() []*frontierv1beta1.PreferenceTrait { + return []*frontierv1beta1.PreferenceTrait{ + platformTraitPB("disable_orgs_on_create", "false"), + platformTraitPB("invite_with_roles", "true"), + // a non-platform trait: it must be ignored, so its name is never valid here + {Name: "mail_otp", Default: "false", ResourceType: schema.OrganizationNamespace}, + } +} + +func TestPreferenceReconciler(t *testing.T) { + t.Run("applies a set and a reset", func(t *testing.T) { + api := &fakePreferenceAPI{ + traits: platformTraits(), + prefs: []*frontierv1beta1.Preference{ + prefPB("invite_with_roles", "false"), // stored, not listed -> reset to "true" + }, + } + spec := []byte("- {name: disable_orgs_on_create, value: \"true\"}\n") + + rep, err := NewPreferenceReconciler(api, "").Reconcile(context.Background(), spec, false) + + assert.NoError(t, err) + assert.Equal(t, []string{ + "set preference disable_orgs_on_create = true", + "reset preference invite_with_roles to default", + }, rep.Planned) + assert.Equal(t, 2, rep.Applied) + + got := map[string]string{} + for _, b := range api.created { + got[b.GetName()] = b.GetValue() + } + assert.Equal(t, map[string]string{"disable_orgs_on_create": "true", "invite_with_roles": "true"}, got) + }) + + t.Run("a dry run plans without applying", func(t *testing.T) { + api := &fakePreferenceAPI{traits: platformTraits()} + spec := []byte("- {name: disable_orgs_on_create, value: \"true\"}\n") + + rep, err := NewPreferenceReconciler(api, "").Reconcile(context.Background(), spec, true) + + assert.NoError(t, err) + assert.Equal(t, []string{"set preference disable_orgs_on_create = true"}, rep.Planned) + assert.Zero(t, rep.Applied) + assert.Empty(t, api.created) + }) + + t.Run("an unknown preference in the file fails before applying", func(t *testing.T) { + api := &fakePreferenceAPI{traits: platformTraits()} + spec := []byte("- {name: mail_otp, value: \"true\"}\n") // org trait, not a platform one + + _, err := NewPreferenceReconciler(api, "").Reconcile(context.Background(), spec, false) + + assert.ErrorContains(t, err, `unknown platform preference "mail_otp"`) + assert.Empty(t, api.created) + }) + + t.Run("an unknown field in the spec fails the plan", func(t *testing.T) { + api := &fakePreferenceAPI{traits: platformTraits()} + // `valu` instead of `value`: must fail, not silently ignore the value + spec := []byte("- {name: disable_orgs_on_create, valu: \"true\"}\n") + + _, err := NewPreferenceReconciler(api, "").Reconcile(context.Background(), spec, true) + + assert.ErrorContains(t, err, "parse Preference spec") + assert.Empty(t, api.created) + }) + + t.Run("export returns only overrides, sorted, and round-trips", func(t *testing.T) { + api := &fakePreferenceAPI{ + traits: platformTraits(), + prefs: []*frontierv1beta1.Preference{ + prefPB("invite_with_roles", "true"), // equals default: omitted + prefPB("disable_orgs_on_create", "true"), // override: exported + }, + } + registry := map[string]Reconciler{KindPreference: NewPreferenceReconciler(api, "")} + + out, err := Export(context.Background(), registry, KindPreference) + assert.NoError(t, err) + assert.Contains(t, string(out), "disable_orgs_on_create") + assert.NotContains(t, string(out), "invite_with_roles") + + 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 with everything at default yields an empty list", func(t *testing.T) { + api := &fakePreferenceAPI{traits: platformTraits()} + registry := map[string]Reconciler{KindPreference: NewPreferenceReconciler(api, "")} + + out, err := Export(context.Background(), registry, KindPreference) + assert.NoError(t, err) + assert.Contains(t, string(out), "spec: []") + }) +} diff --git a/internal/reconcile/preference_test.go b/internal/reconcile/preference_test.go new file mode 100644 index 000000000..8bbf4afd3 --- /dev/null +++ b/internal/reconcile/preference_test.go @@ -0,0 +1,123 @@ +package reconcile + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDiffPreferences(t *testing.T) { + defaults := map[string]string{ + "disable_orgs_on_create": "false", + "disable_orgs_listing": "false", + "invite_with_roles": "true", + } + + t.Run("no changes when the file matches the server", func(t *testing.T) { + current := map[string]string{"disable_orgs_on_create": "true"} + ops, err := diffPreferences([]PreferenceSpec{ + {Name: "disable_orgs_on_create", Value: "true"}, + }, current, defaults) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + t.Run("sets a value that differs from the server", func(t *testing.T) { + current := map[string]string{"disable_orgs_on_create": "false"} + ops, err := diffPreferences([]PreferenceSpec{ + {Name: "disable_orgs_on_create", Value: "true"}, + }, current, defaults) + assert.NoError(t, err) + if assert.Len(t, ops, 1) { + assert.Equal(t, "set preference disable_orgs_on_create = true", ops[0].String()) + assert.Equal(t, "true", ops[0].value) + } + }) + + t.Run("sets a value that is stored nowhere but differs from the default", func(t *testing.T) { + // not in the DB yet: the effective value is the default "false" + ops, err := diffPreferences([]PreferenceSpec{ + {Name: "disable_orgs_on_create", Value: "true"}, + }, map[string]string{}, defaults) + assert.NoError(t, err) + if assert.Len(t, ops, 1) { + assert.Equal(t, opSet, ops[0].action) + } + }) + + t.Run("a value equal to the default and stored nowhere is a no-op", func(t *testing.T) { + ops, err := diffPreferences([]PreferenceSpec{ + {Name: "disable_orgs_on_create", Value: "false"}, + }, map[string]string{}, defaults) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + t.Run("resets a stored preference that the file leaves out", func(t *testing.T) { + current := map[string]string{ + "disable_orgs_on_create": "true", + "invite_with_roles": "false", + } + ops, err := diffPreferences([]PreferenceSpec{ + {Name: "disable_orgs_on_create", Value: "true"}, + }, current, defaults) + assert.NoError(t, err) + if assert.Len(t, ops, 1) { + assert.Equal(t, "reset preference invite_with_roles to default", ops[0].String()) + assert.Equal(t, "true", ops[0].value) // the default is written back + } + }) + + t.Run("sets run before resets, each sorted by name", func(t *testing.T) { + current := map[string]string{ + "disable_orgs_listing": "true", // not listed -> reset + "invite_with_roles": "true", // not listed but equals default -> untouched + } + ops, err := diffPreferences([]PreferenceSpec{ + {Name: "disable_orgs_on_create", Value: "true"}, // set + }, current, defaults) + assert.NoError(t, err) + if assert.Len(t, ops, 2) { + assert.Equal(t, "set preference disable_orgs_on_create = true", ops[0].String()) + assert.Equal(t, "reset preference disable_orgs_listing to default", ops[1].String()) + } + }) + + t.Run("an empty stored value counts as the default", func(t *testing.T) { + // The server treats an empty stored value as unset and falls back to the + // default, so a file entry equal to the default must plan no change. + current := map[string]string{"disable_orgs_on_create": ""} + ops, err := diffPreferences([]PreferenceSpec{ + {Name: "disable_orgs_on_create", Value: "false"}, // the default + }, current, defaults) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + t.Run("an unknown preference name fails the plan", func(t *testing.T) { + _, err := diffPreferences([]PreferenceSpec{ + {Name: "not_a_trait", Value: "x"}, + }, map[string]string{}, defaults) + assert.ErrorContains(t, err, `unknown platform preference "not_a_trait"`) + }) + + t.Run("a duplicate name fails the plan", func(t *testing.T) { + _, err := diffPreferences([]PreferenceSpec{ + {Name: "invite_with_roles", Value: "true"}, + {Name: "invite_with_roles", Value: "false"}, + }, map[string]string{}, defaults) + assert.ErrorContains(t, err, "listed more than once") + }) + + t.Run("an empty name fails the plan", func(t *testing.T) { + _, err := diffPreferences([]PreferenceSpec{{Name: "", Value: "x"}}, map[string]string{}, defaults) + assert.ErrorContains(t, err, "name is required") + }) + + t.Run("a stored value whose trait no longer exists is left alone", func(t *testing.T) { + current := map[string]string{"gone_trait": "whatever"} + ops, err := diffPreferences(nil, current, defaults) + assert.NoError(t, err) + assert.Empty(t, ops) + }) +}