-
Notifications
You must be signed in to change notification settings - Fork 44
feat(reconcile): add a Preference kind #1762
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
fb8b4a1
feat(reconcile): add a Preference kind that resets unlisted settings …
rohilsurana 4c0d165
feat(reconcile): register the Preference kind in the reconcile command
rohilsurana 45160a6
docs(reconcile): document the Preference kind
rohilsurana c21a46e
fix(reconcile): treat an empty stored preference value as the default…
rohilsurana c094c3e
fix(reconcile): reject unknown fields in the Preference spec (strict …
rohilsurana File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}) | ||
| } | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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: compareserverValue(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-L92internal/reconcile/preference_test.go#L86-L95internal/reconcile/preference_reconciler_test.go#L108-L137