| new file mode 100644 |
| index 000000000..e2faaab6d |
| --- /dev/null |
| +++ b/internal/release/updatesettings.go |
| @@ -0,0 +1,89 @@ |
| +package release |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "slices" |
| +) |
| + |
| +// The update settings vocabulary (instance update settings spec §3). |
| +const ( |
| + UpdatesAuto = "auto" |
| + UpdatesManual = "manual" |
| + |
| + ControlUpdates = "updates" |
| + ControlChannel = "channel" |
| + ControlMajor = "major" |
| + ControlPin = "pin" |
| +) |
| + |
| +// UpdateSettings are one instance's EFFECTIVE settings: the record's explicit |
| +// values over the app's defaults, resolved by reconcile |
| +// (provision.EffectiveUpdates) and carried to root in the registry's |
| +// update_settings column. A preference, not a security boundary (spec §2 |
| +// decision 5): root still installs only builds §4.1 authorizes. |
| +type UpdateSettings struct { |
| + Updates string `json:"updates"` |
| + Channel string `json:"channel"` |
| + Major *int `json:"major,omitempty"` |
| + Pin string `json:"pin,omitempty"` |
| + InstallRequest uint64 `json:"install_request,omitempty"` |
| + InstallFastTrack bool `json:"install_fast_track,omitempty"` |
| + Control []string `json:"control,omitempty"` |
| +} |
| + |
| +// ParseUpdateSettings reads the column. "" is every row before this feature, |
| +// and reads as auto: today's adoption, byte for byte. |
| +func ParseUpdateSettings(col string) (UpdateSettings, error) { |
| + if col == "" { |
| + return UpdateSettings{Updates: UpdatesAuto}, nil |
| + } |
| + var u UpdateSettings |
| + if err := json.Unmarshal([]byte(col), &u); err != nil { |
| + return UpdateSettings{}, fmt.Errorf("update settings: %v", err) |
| + } |
| + if u.Updates != UpdatesAuto && u.Updates != UpdatesManual { |
| + return UpdateSettings{}, fmt.Errorf("update settings: updates %q", u.Updates) |
| + } |
| + if u.Major != nil && *u.Major < 0 { |
| + return UpdateSettings{}, fmt.Errorf("update settings: major %d", *u.Major) |
| + } |
| + if u.Pin != "" && (!ValidVersion(u.Pin) || u.Pin == "dev") { |
| + return UpdateSettings{}, fmt.Errorf("update settings: pin %q", u.Pin) |
| + } |
| + if u.Channel != "" && !ValidChannel(u.Channel) { |
| + return UpdateSettings{}, fmt.Errorf("update settings: channel %q", u.Channel) |
| + } |
| + if !ValidControl(u.Control) { |
| + return UpdateSettings{}, fmt.Errorf("update settings: control %q", u.Control) |
| + } |
| + return u, nil |
| +} |
| + |
| +// Column is the canonical JSON reconcile writes. Default settings are "": |
| +// a row is byte-identical to before this feature until someone sets |
| +// something. |
| +func (u UpdateSettings) Column() string { |
| + if u.Default() && len(u.Control) == 0 { |
| + return "" |
| + } |
| + b, _ := json.Marshal(u) |
| + return string(b) |
| +} |
| + |
| +// Default reports today's behaviour: auto, no major hold, no pin, no request. |
| +func (u UpdateSettings) Default() bool { |
| + return (u.Updates == "" || u.Updates == UpdatesAuto) && u.Major == nil && u.Pin == "" && u.InstallRequest == 0 |
| +} |
| + |
| +// ValidControl accepts a set of setting names an instance may change itself. |
| +func ValidControl(names []string) bool { |
| + seen := map[string]bool{} |
| + for _, n := range names { |
| + if seen[n] || !slices.Contains([]string{ControlUpdates, ControlChannel, ControlMajor, ControlPin}, n) { |
| + return false |
| + } |
| + seen[n] = true |
| + } |
| + return true |
| +} |