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>
3 files changed,
+183
−0
internal/provision/provision.go+20 −0internal/provision/updates.go+85 −0internal/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") |
| + } |
| +} |