carlos / platform Public

settings-3 task 2: update settings on the record, the policy on the instances manifest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 891945c63c9e44364ee03022a955d295f2436fd7 parent 34d4d76
3 files changed, +183 −0
  • internal/provision/provision.go +20 −0
  • internal/provision/updates.go +85 −0
  • internal/provision/updates_test.go +78 −0
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 94542d08e..e0dd94467 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -191,6 +191,21 @@ type Record struct {
// steering converger — and never read by the edge's own dispatch,
// which never hairpins a replica.
Replicas []string `json:"replicas,omitempty"`
+
+ // --- update settings (instance update settings spec §3.1, §4.2) -----
+ // Every one is omitempty with "never set" as its zero value, so a record
+ // written before them decodes with none and reconcile resolves it to
+ // auto: today's adoption. Set only through the console's /updates doors.
+
+ // Updates is "auto", "manual", or "" (never set: the app default).
+ Updates string `json:"updates,omitempty"`
+ // Major holds the instance on one semver major of stable. nil: never set.
+ Major *int `json:"major,omitempty"`
+ // Pin is the exact build to run. It must have a promotion record.
+ Pin string `json:"pin,omitempty"`
+ // InstallRequest is "Update now": an id that only ever increases. Root
+ // resolves and completes it (plan 4); the console never chooses a build.
+ InstallRequest *InstallRequest `json:"install_request,omitempty"`
}
// Health is the per-route probe config (HA spec Part II). Zero value =
@@ -265,6 +280,11 @@ type Manifest struct {
// Moves is the app's move declaration (move-any-app design §7.3 S1):
// the readiness path a probe requests. Absent means "/".
Moves *MovesManifest `json:"moves,omitempty"`
+
+ // Updates is the app's instance update policy (spec §3.2). nil: no
+ // instance may change anything itself, and the defaults are auto, this
+ // manifest's Channel, and no major hold.
+ Updates *UpdatePolicy `json:"updates,omitempty"`
}
type MovesManifest struct {
diff --git a/internal/provision/updates.go b/internal/provision/updates.go
new file mode 100644
index 000000000..7fd06a532
--- /dev/null
+++ b/internal/provision/updates.go
@@ -0,0 +1,85 @@
+package provision
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// InstallRequest is one "Update now" (spec §4.2).
+type InstallRequest struct {
+ ID uint64 `json:"id"`
+ FastTrack bool `json:"fast_track,omitempty"`
+ At time.Time `json:"at"`
+ By string `json:"by,omitempty"`
+}
+
+// UpdatePolicy is the app's instance update policy (spec §3.2). The default
+// channel is the manifest's own Channel (index Q4).
+type UpdatePolicy struct {
+ InstanceControl []string `json:"instance_control,omitempty"`
+ DefaultUpdates string `json:"default_updates,omitempty"`
+ DefaultMajor *int `json:"default_major,omitempty"`
+}
+
+// EffectiveUpdates resolves one instance's settings: its own explicit values,
+// else the app's defaults, else today's. A major hold off stable is dropped:
+// only stable promotions cut tags (spec §3.1), so it could never hold.
+func EffectiveUpdates(rec Record, p *UpdatePolicy) release.UpdateSettings {
+ u := release.UpdateSettings{Updates: release.UpdatesAuto, Channel: rec.Channel, Pin: rec.Pin}
+ if p != nil {
+ if p.DefaultUpdates != "" {
+ u.Updates = p.DefaultUpdates
+ }
+ u.Major = p.DefaultMajor
+ u.Control = append([]string(nil), p.InstanceControl...)
+ }
+ if rec.Updates != "" {
+ u.Updates = rec.Updates
+ }
+ if rec.Major != nil {
+ u.Major = rec.Major
+ }
+ if u.Channel != "stable" {
+ u.Major = nil
+ }
+ if rec.InstallRequest != nil {
+ u.InstallRequest, u.InstallFastTrack = rec.InstallRequest.ID, rec.InstallRequest.FastTrack
+ }
+ return u
+}
+
+// ValidateUpdates is the record's door check (the console's PUT).
+func ValidateUpdates(rec Record) error {
+ if rec.Updates != "" && rec.Updates != release.UpdatesAuto && rec.Updates != release.UpdatesManual {
+ return fmt.Errorf("updates must be auto or manual")
+ }
+ if rec.Major != nil && *rec.Major < 0 {
+ return fmt.Errorf("major must be a whole number, 0 or more")
+ }
+ if rec.Major != nil && rec.Channel != "stable" {
+ return fmt.Errorf("major applies only to the stable channel")
+ }
+ if rec.Pin != "" && (!release.ValidVersion(rec.Pin) || rec.Pin == "dev") {
+ return fmt.Errorf("invalid pin %q", rec.Pin)
+ }
+ return nil
+}
+
+// ValidatePolicy is the app policy's door check.
+func ValidatePolicy(p *UpdatePolicy) error {
+ if p == nil {
+ return nil
+ }
+ if !release.ValidControl(p.InstanceControl) {
+ return fmt.Errorf("instance_control must name updates, channel, major or pin, each once")
+ }
+ if p.DefaultUpdates != "" && p.DefaultUpdates != release.UpdatesAuto && p.DefaultUpdates != release.UpdatesManual {
+ return fmt.Errorf("default_updates must be auto or manual")
+ }
+ if p.DefaultMajor != nil && *p.DefaultMajor < 0 {
+ return fmt.Errorf("default_major must be a whole number, 0 or more")
+ }
+ return nil
+}
diff --git a/internal/provision/updates_test.go b/internal/provision/updates_test.go
new file mode 100644
index 000000000..a2d12f87d
--- /dev/null
+++ b/internal/provision/updates_test.go
@@ -0,0 +1,78 @@
+package provision
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func intp(n int) *int { return &n }
+
+func TestARecordWithoutSettingsIsToday(t *testing.T) {
+ rec := Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable"}
+ u := EffectiveUpdates(rec, nil)
+ if !u.Default() || u.Channel != "stable" || u.Column() != "" {
+ t.Fatalf("EffectiveUpdates = %+v (column %q)", u, u.Column())
+ }
+ b, _ := json.Marshal(rec)
+ for _, k := range []string{`"updates"`, `"major"`, `"pin"`, `"install_request"`} {
+ if strings.Contains(string(b), k) {
+ t.Fatalf("an unset %s leaked into the record: %s", k, b)
+ }
+ }
+}
+
+func TestDefaultsApplyWhereNothingIsSet(t *testing.T) {
+ p := &UpdatePolicy{DefaultUpdates: release.UpdatesManual, DefaultMajor: intp(3), InstanceControl: []string{"pin"}}
+ u := EffectiveUpdates(Record{Channel: "stable"}, p)
+ if u.Updates != release.UpdatesManual || *u.Major != 3 || len(u.Control) != 1 {
+ t.Fatalf("defaults = %+v", u)
+ }
+ u = EffectiveUpdates(Record{Channel: "stable", Updates: release.UpdatesAuto, Major: intp(4)}, p)
+ if u.Updates != release.UpdatesAuto || *u.Major != 4 {
+ t.Fatalf("explicit values lost to defaults: %+v", u)
+ }
+}
+
+// Review Focus 1.
+func TestEffectiveUpdatesDropsMajorOffStable(t *testing.T) {
+ u := EffectiveUpdates(Record{Channel: "beta", Major: intp(2)}, &UpdatePolicy{DefaultMajor: intp(1)})
+ if u.Major != nil {
+ t.Fatalf("major off stable = %d; it can never apply there", *u.Major)
+ }
+}
+
+func TestEffectiveUpdatesCarriesTheRequest(t *testing.T) {
+ u := EffectiveUpdates(Record{Channel: "stable", InstallRequest: &InstallRequest{ID: 7, FastTrack: true}}, nil)
+ if u.InstallRequest != 7 || !u.InstallFastTrack {
+ t.Fatalf("request = %+v", u)
+ }
+}
+
+func TestValidateUpdates(t *testing.T) {
+ ok := []Record{
+ {Channel: "stable"},
+ {Channel: "stable", Updates: "manual", Major: intp(0), Pin: "v1"},
+ }
+ for _, r := range ok {
+ if err := ValidateUpdates(r); err != nil {
+ t.Errorf("%+v: %v", r, err)
+ }
+ }
+ bad := []Record{
+ {Channel: "stable", Updates: "sometimes"},
+ {Channel: "stable", Major: intp(-1)},
+ {Channel: "beta", Major: intp(1)},
+ {Channel: "stable", Pin: "../etc"},
+ }
+ for _, r := range bad {
+ if err := ValidateUpdates(r); err == nil {
+ t.Errorf("%+v was accepted", r)
+ }
+ }
+ if ValidatePolicy(&UpdatePolicy{InstanceControl: []string{"bake"}}) == nil || ValidatePolicy(&UpdatePolicy{DefaultUpdates: "x"}) == nil {
+ t.Fatal("ValidatePolicy accepted nonsense")
+ }
+}