Merge pull request 'The licence manager counts room from the weekly window alone, so the switch finds a target (issue 1409)' (#661) from fix/1409-weekly-only into main

This commit was merged in pull request #661.
This commit is contained in:
2026-11-12 10:18:53 +00:00
9 changed files with 70 additions and 67 deletions
+4 -4
View File
@@ -55,10 +55,10 @@ reads alone and answers every act with "the carry-over has not ended"; a failed
## Switching on usage ## Switching on usage
Every `usage_minutes` the manager reads each subscription's usage with its access token from the vault: the session, Every `usage_minutes` the manager reads each subscription's usage with its access token from the vault: the session,
weekly and sonnet windows, each with its reset time. A licence's room is 100 less the higher of its session and weekly weekly and sonnet windows, each with its reset time. A licence's room is 100 less its weekly percentage alone (the session
percentages, known only when it was read within `usage_stale_minutes` and its refresh is healthy. When a reading puts window takes no part in room, the switch, the warning or the conditions: the vendor retired the session limit), known only when it was read within `usage_stale_minutes` and its refresh is healthy. When a reading puts
a licence with consumers at or above `switch_at_pct`, every consumer bound to it moves, in one act, to the licence a licence with consumers at or above `switch_at_pct` in its weekly window, every consumer bound to it moves, in one act, to the licence
with the most room whose windows are both below `room_below_pct`: one `binding.changed` per consumer, one with the most room whose weekly window is below `room_below_pct`: one `binding.changed` per consumer, one
`licence.switched`, and one `operator-channel.tell`. A consumer is never moved back by the rule. `licence.switched`, and one `operator-channel.tell`. A consumer is never moved back by the rule.
Its conditions are said on the operator channel's condition subject, the router deciding where they go: Its conditions are said on the operator channel's condition subject, the router deciding where they go:
@@ -227,16 +227,16 @@ func (m *Manager) unread(l Licence, s Settings) (string, bool) {
func (m *Manager) roomWords(o Licence, s Settings) string { func (m *Manager) roomWords(o Licence, s Settings) string {
room, known := Room(o, m.Now(), s) room, known := Room(o, m.Now(), s)
switch { switch {
case known && (*o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow): case known && *o.Usage.WeeklyPct >= s.RoomBelow:
return fmt.Sprintf("has %g %% room, a window not below %g %%", room, s.RoomBelow) return fmt.Sprintf("has %g %% room, its weekly window not below %g %%", room, s.RoomBelow)
case known: case known:
return fmt.Sprintf("has %g %% room", room) return fmt.Sprintf("has %g %% room", room)
case o.Kind != "subscription": case o.Kind != "subscription":
return "is an API key, whose room is never known" return "is an API key, whose room is never known"
case o.Usage == nil || o.Usage.ReadAt == "": case o.Usage == nil || o.Usage.ReadAt == "":
return "has no reading" return "has no reading"
case o.Usage.SessionPct == nil || o.Usage.WeeklyPct == nil: case o.Usage.WeeklyPct == nil:
return "has no session or weekly window in its reading" return "has no weekly window in its reading"
case o.Failures >= s.FailuresToNotify || (o.RefreshExpiresAt != 0 && o.RefreshExpiresAt <= m.Now().UnixMilli()): case o.Failures >= s.FailuresToNotify || (o.RefreshExpiresAt != 0 && o.RefreshExpiresAt <= m.Now().UnixMilli()):
return "does not refresh" return "does not refresh"
} }
@@ -140,7 +140,7 @@ func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) {
ctx := context.Background() ctx := context.Background()
mm := fourOnA(t) mm := fourOnA(t)
_, said := saying(mm) _, said := saying(mm)
_ = mm.store.SaveLicence(ctx, sub("b", read(95, 20))) _ = mm.store.SaveLicence(ctx, sub("b", read(20, 95)))
_ = mm.store.SaveLicence(ctx, sub("c", read(40, 99))) _ = mm.store.SaveLicence(ctx, sub("c", read(40, 99)))
for range 2 { for range 2 {
_ = mm.m.SayConditions(ctx, false) _ = mm.m.SayConditions(ctx, false)
@@ -158,7 +158,7 @@ func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) {
if c.Severity != occ.SeverityUrgent || c.Headline != "No licence has room for 4 machines" || if c.Severity != occ.SeverityUrgent || c.Headline != "No licence has room for 4 machines" ||
c.Needs != "add a licence, or wait for a's weekly window to reset at 2026-11-14T09:00:00Z" || c.Needs != "add a licence, or wait for a's weekly window to reset at 2026-11-14T09:00:00Z" ||
!strings.Contains(c.Explanation, "a is at 99 % of its weekly window, with ace, g14, laptop, server bound to it") || !strings.Contains(c.Explanation, "a is at 99 % of its weekly window, with ace, g14, laptop, server bound to it") ||
!strings.Contains(c.Explanation, "b has 5 % room, a window not below 90 %") || !strings.Contains(c.Explanation, "b has 5 % room, its weekly window not below 90 %") ||
c.Resolved != "ace, g14, laptop, server have a licence with room again" { c.Resolved != "ace, g14, laptop, server have a licence with room again" {
t.Fatalf("no-room %+v", c) t.Fatalf("no-room %+v", c)
} }
@@ -178,7 +178,7 @@ func TestNearLimitIsSaidOncePerCrossing(t *testing.T) {
key := "licence.a.near-limit" key := "licence.a.near-limit"
var last oc.Condition var last oc.Condition
for _, w := range []float64{80, 89, 91, 91, 95} { for _, w := range []float64{80, 89, 91, 91, 95} {
_ = mm.store.SaveLicence(ctx, sub("a", read(10, w))) _ = mm.store.SaveLicence(ctx, sub("a", read(95, w))) // a session at 95 % takes no part (C4)
_ = mm.m.SayConditions(ctx, false) _ = mm.m.SayConditions(ctx, false)
for _, s := range *said { for _, s := range *said {
if s.Key == key { if s.Key == key {
@@ -191,8 +191,8 @@ func TestNearLimitIsSaidOncePerCrossing(t *testing.T) {
!strings.Contains(last.Explanation, "which resets at 2026-11-14T09:00:00Z") { !strings.Contains(last.Explanation, "which resets at 2026-11-14T09:00:00Z") {
t.Fatalf("said %v, last %+v", of(*said, key), last) t.Fatalf("said %v, last %+v", of(*said, key), last)
} }
_ = mm.store.SaveLicence(ctx, sub("b", read(95, 0))) _ = mm.store.SaveLicence(ctx, sub("b", read(0, 95)))
_ = mm.store.SaveLicence(ctx, sub("c", read(95, 0))) _ = mm.store.SaveLicence(ctx, sub("c", read(0, 95)))
_ = mm.m.SayConditions(ctx, false) _ = mm.m.SayConditions(ctx, false)
if c := (*said)[len(*said)-1]; c.Key != key || c.Needs != "no other licence has room" { if c := (*said)[len(*said)-1]; c.Key != key || c.Needs != "no other licence has room" {
t.Fatalf("with no target said %+v", c) t.Fatalf("with no target said %+v", c)
@@ -1,18 +1,15 @@
package main package main
// A licence's room (design 39 bus-and-switch §25, §48): 100 less the higher of its session and weekly percentages in // A licence's room (design 39 bus-and-switch §25, §48, C4): 100 less its weekly percentage in its latest reading, the
// its latest reading, known only for a subscription read successfully within usage_stale_minutes, whose refresh has not // session window taking no part (the vendor retired the session limit), known only for a subscription read successfully within usage_stale_minutes, whose refresh has not
// failed failures_to_notify times in a row and whose refresh token has not expired. An API key's room is never known, // failed failures_to_notify times in a row and whose refresh token has not expired. An API key's room is never known,
// so it is never a target. // so it is never a target.
import ( import "time"
"math"
"time"
)
// Room is a licence's room at now, and whether it is known. // Room is a licence's room at now, and whether it is known.
func Room(l Licence, now time.Time, s Settings) (float64, bool) { func Room(l Licence, now time.Time, s Settings) (float64, bool) {
if l.Kind != "subscription" || l.Usage == nil || l.Usage.SessionPct == nil || l.Usage.WeeklyPct == nil { if l.Kind != "subscription" || l.Usage == nil || l.Usage.WeeklyPct == nil {
return 0, false return 0, false
} }
read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt) read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt)
@@ -22,5 +19,5 @@ func Room(l Licence, now time.Time, s Settings) (float64, bool) {
if l.Failures >= s.FailuresToNotify || (l.RefreshExpiresAt != 0 && l.RefreshExpiresAt <= now.UnixMilli()) { if l.Failures >= s.FailuresToNotify || (l.RefreshExpiresAt != 0 && l.RefreshExpiresAt <= now.UnixMilli()) {
return 0, false return 0, false
} }
return 100 - math.Max(*l.Usage.SessionPct, *l.Usage.WeeklyPct), true return 100 - *l.Usage.WeeklyPct, true
} }
@@ -89,7 +89,7 @@ func TestAFailedReadingKeepsThePercentages(t *testing.T) {
} }
} }
// design 39 bus-and-switch §25/AC1, §48: a table of readings, ages and healths and the room each gives. // design 39 bus-and-switch §25/AC1, §48, C4: a table of readings, ages and healths and the room each gives.
func TestTheRoomOfEachLicence(t *testing.T) { func TestTheRoomOfEachLicence(t *testing.T) {
now := t0 now := t0
pct := func(v float64) *float64 { return &v } pct := func(v float64) *float64 { return &v }
@@ -102,8 +102,10 @@ func TestTheRoomOfEachLicence(t *testing.T) {
room float64 room float64
known bool known bool
}{ }{
{"the higher window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true}, {"the weekly window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true},
{"the session can be the higher", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 20, true}, {"the session takes no part (C4)", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 80, true},
{"no session window, weekly 33 (C4)", Licence{Kind: "subscription", Usage: &UsageReading{WeeklyPct: pct(33),
ReadAt: now.Format(time.RFC3339Nano)}}, 67, true},
{"read at the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 15*time.Minute)}, 90, true}, {"read at the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 15*time.Minute)}, 90, true},
{"read before the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 16*time.Minute)}, 0, false}, {"read before the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 16*time.Minute)}, 0, false},
{"a failure since keeps the room", Licence{Kind: "subscription", Usage: func() *UsageReading { {"a failure since keeps the room", Licence{Kind: "subscription", Usage: func() *UsageReading {
@@ -1,9 +1,9 @@
package main package main
// The switch rule (design 39 bus-and-switch §26, §27, §28, §32): after a reading of licence L, when switch_auto is on and // The switch rule (design 39 bus-and-switch §26, §27, §28, §32): after a reading of licence L, when switch_auto is on and
// L, with one or more consumers bound to it, is at or above switch_at_pct in its session or weekly window, every // L, with one or more consumers bound to it, is at or above switch_at_pct in its weekly window (C4: the session window takes
// consumer bound to L moves, in one act, to the other licence with known room whose windows are both below // no part), every consumer bound to L moves, in one act, to the other licence with known room whose weekly window is
// room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a // below room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a
// binding.changed per consumer, one audit entry switched, and told to the operator. A consumer is only ever moved off // binding.changed per consumer, one audit entry switched, and told to the operator. A consumer is only ever moved off
// a licence at its threshold, so none flaps between two. // a licence at its threshold, so none flaps between two.
@@ -61,24 +61,17 @@ func consumersOf(licence string, bindings []Binding) []string {
return consumers return consumers
} }
// windowAt is l's window at or above pct, the higher of the two, the session window on a tie (B36): its name, // windowAt is l's weekly window when it is at or above pct (C4: the session window takes no part): its name,
// percentage and reset; ok false when neither is. // percentage and reset; ok false when it is not.
func windowAt(u *UsageReading, pct float64) (window string, at float64, resetsAt string, ok bool) { func windowAt(u *UsageReading, pct float64) (window string, at float64, resetsAt string, ok bool) {
if u == nil { if u == nil || u.WeeklyPct == nil || *u.WeeklyPct < pct {
return "", 0, "", false return "", 0, "", false
} }
session, weekly := u.SessionPct, u.WeeklyPct return "weekly", *u.WeeklyPct, u.WeeklyResetsAt, true
switch {
case session != nil && *session >= pct && (weekly == nil || *session >= *weekly):
return "session", *session, u.SessionResetsAt, true
case weekly != nil && *weekly >= pct:
return "weekly", *weekly, u.WeeklyResetsAt, true
}
return "", 0, "", false
} }
// targetFor is the licence §26 moves l's consumers to: among the others with known room whose windows are both below // targetFor is the licence §26 moves l's consumers to: among the others with known room whose weekly window is below
// room_below_pct, the one with the most room, the first by name on a tie; empty when none has room. // room_below_pct (C4), the one with the most room, the first by name on a tie; empty when none has room.
func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string, float64) { func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string, float64) {
others := append([]Licence(nil), licences...) others := append([]Licence(nil), licences...)
sort.Slice(others, func(i, j int) bool { return others[i].Name < others[j].Name }) sort.Slice(others, func(i, j int) bool { return others[i].Name < others[j].Name })
@@ -88,7 +81,7 @@ func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string,
continue continue
} }
room, known := Room(o, m.Now(), s) room, known := Room(o, m.Now(), s)
if !known || *o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best { if !known || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best {
continue continue
} }
best, to = room, o.Name best, to = room, o.Name
@@ -14,11 +14,18 @@ func read(session, weekly float64) *UsageReading {
WeeklyResetsAt: "2026-11-14T09:00:00Z", ReadAt: t0.UTC().Format(time.RFC3339Nano)} WeeklyResetsAt: "2026-11-14T09:00:00Z", ReadAt: t0.UTC().Format(time.RFC3339Nano)}
} }
// weekly is a reading with no session window, as the vendor answers since the session limit was retired (C4).
func weekly(pct float64) *UsageReading {
u := read(0, pct)
u.SessionPct, u.SessionResetsAt = nil, ""
return u
}
func sub(name string, u *UsageReading) Licence { func sub(name string, u *UsageReading) Licence {
return Licence{Name: name, Kind: "subscription", Generation: 1, Usage: u} return Licence{Name: name, Kind: "subscription", Generation: 1, Usage: u}
} }
// design 39 bus-and-switch §26/AC1, §25, §48: a table of licences, readings and bindings and the moves each gives. // design 39 bus-and-switch §26/AC1, §25, §48, C4: a table of licences, readings and bindings and the moves each gives.
func TestTheRuleMovesEachTable(t *testing.T) { func TestTheRuleMovesEachTable(t *testing.T) {
mm := newMesh(t) mm := newMesh(t)
on := func(consumers ...string) []Binding { on := func(consumers ...string) []Binding {
@@ -41,12 +48,15 @@ func TestTheRuleMovesEachTable(t *testing.T) {
{"no consumer", []Licence{sub("a", read(99, 99)), sub("b", read(0, 0))}, nil, "", "", false, 0}, {"no consumer", []Licence{sub("a", read(99, 99)), sub("b", read(0, 0))}, nil, "", "", false, 0},
{"the weekly window at 99", []Licence{sub("a", read(40, 99)), sub("b", read(10, 30)), sub("c", read(5, 20))}, {"the weekly window at 99", []Licence{sub("a", read(40, 99)), sub("b", read(10, 30)), sub("c", read(5, 20))},
on("x", "y", "z", "w"), "c", "weekly", false, 4}, on("x", "y", "z", "w"), "c", "weekly", false, 4},
{"the session window at 100", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))}, on("x"), "b", "session", false, 1}, {"the session window at 100 moves nothing (C4)", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))},
{"a tie goes to the first by name", []Licence{sub("a", read(99, 0)), sub("c", read(10, 10)), sub("b", read(10, 10))}, on("x"), "", "", false, 0},
on("x"), "b", "session", false, 1}, {"no session window, weekly 99, to weekly 33 (C4)", []Licence{sub("a", weekly(99)), sub("b", weekly(33))},
{"a target's windows both below room_below_pct", []Licence{sub("a", read(99, 0)), sub("b", read(0, 90)), on("x", "y"), "b", "weekly", false, 2},
sub("c", read(89, 89))}, on("x"), "c", "session", false, 1}, {"a tie goes to the first by name", []Licence{sub("a", read(0, 99)), sub("c", read(10, 10)), sub("b", read(10, 10))},
{"no other licence has room", []Licence{sub("a", read(99, 0)), sub("b", read(95, 0)), on("x"), "b", "weekly", false, 1},
{"a target's weekly below room_below_pct, its session not counted (C4)", []Licence{sub("a", read(0, 99)),
sub("b", read(0, 90)), sub("c", read(95, 89))}, on("x"), "c", "weekly", false, 1},
{"no other licence has room", []Licence{sub("a", read(0, 99)), sub("b", read(0, 95)),
{Name: "k", Kind: "api-key", Generation: 1}}, on("x"), "", "", true, 0}, {Name: "k", Kind: "api-key", Generation: 1}}, on("x"), "", "", true, 0},
{"an unread licence is no target", []Licence{sub("a", read(0, 99)), sub("b", nil)}, on("x"), "", "", true, 0}, {"an unread licence is no target", []Licence{sub("a", read(0, 99)), sub("b", nil)}, on("x"), "", "", true, 0},
} { } {
@@ -65,7 +75,7 @@ func TestTheRuleMovesEachTable(t *testing.T) {
func fourOnA(t *testing.T) *miniMesh { func fourOnA(t *testing.T) *miniMesh {
mm := newMesh(t) mm := newMesh(t)
ctx := context.Background() ctx := context.Background()
for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 20)), sub("c", read(40, 10))} { for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 30)), sub("c", read(40, 40))} {
_ = mm.store.SaveLicence(ctx, l) _ = mm.store.SaveLicence(ctx, l)
} }
for _, c := range []string{"ace", "g14", "laptop", "server"} { for _, c := range []string{"ace", "g14", "laptop", "server"} {
@@ -211,13 +211,13 @@ func fillTodaysRows(t *testing.T, conn *pgx.Conn) {
type vendor struct { type vendor struct {
mu sync.Mutex mu sync.Mutex
calls int calls int
logins map[string][2]string // refresh token: the next access token and the account logins map[string][2]string // refresh token: the next access token and the account
usage map[string][2]float64 // access token: session and weekly percentages usage map[string]float64 // access token: the weekly percentage; the session window is answered empty (C4)
server *httptest.Server server *httptest.Server
} }
func newVendor(t *testing.T) *vendor { func newVendor(t *testing.T) *vendor {
v := &vendor{logins: map[string][2]string{}, usage: map[string][2]float64{}} v := &vendor{logins: map[string][2]string{}, usage: map[string]float64{}}
v.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { v.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
v.mu.Lock() v.mu.Lock()
defer v.mu.Unlock() defer v.mu.Unlock()
@@ -240,16 +240,17 @@ func newVendor(t *testing.T) *vendor {
return return
} }
resets := time.Now().Add(3 * time.Hour).UTC().Format(time.RFC3339) resets := time.Now().Add(3 * time.Hour).UTC().Format(time.RFC3339)
_ = json.NewEncoder(w).Encode(map[string]any{"five_hour": map[string]any{"utilization": u[0], "resets_at": resets}, // As the vendor answers since it retired the session limit: the session window with no utilization (C4).
"seven_day": map[string]any{"utilization": u[1], "resets_at": resets}}) _ = json.NewEncoder(w).Encode(map[string]any{"five_hour": map[string]any{"utilization": nil, "resets_at": nil},
"seven_day": map[string]any{"utilization": u, "resets_at": resets}})
})) }))
t.Cleanup(v.server.Close) t.Cleanup(v.server.Close)
return v return v
} }
func (v *vendor) set(access string, session, weekly float64) { func (v *vendor) set(access string, weekly float64) {
v.mu.Lock() v.mu.Lock()
v.usage[access] = [2]float64{session, weekly} v.usage[access] = weekly
v.mu.Unlock() v.mu.Unlock()
} }
@@ -1065,8 +1066,8 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) {
m.mu.Lock() m.mu.Lock()
carried := m.count("binding.changed") carried := m.count("binding.changed")
m.mu.Unlock() m.mu.Unlock()
m.vendor.set("at-b", 10, 20) m.vendor.set("at-b", 20)
m.vendor.set("at-a", 99, 50) m.vendor.set("at-a", 99)
p.must("licences_read_usage", map[string]any{"licence": licenceB}) p.must("licences_read_usage", map[string]any{"licence": licenceB})
p.must("licences_read_usage", map[string]any{"licence": licenceA}) p.must("licences_read_usage", map[string]any{"licence": licenceA})
m.until("the four consumers moved", func() bool { return m.count("binding.changed") >= carried+4 }) m.until("the four consumers moved", func() bool { return m.count("binding.changed") >= carried+4 })
@@ -1098,8 +1099,8 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) {
// Every licence at its threshold: the consumers have nowhere to go. // Every licence at its threshold: the consumers have nowhere to go.
noRoom := "licence." + KeyFor(licenceB) + ".no-room" noRoom := "licence." + KeyFor(licenceB) + ".no-room"
m.vendor.set("at-a", 99, 50) m.vendor.set("at-a", 99)
m.vendor.set("at-b", 99, 60) m.vendor.set("at-b", 99)
p.must("licences_read_usage", map[string]any{"licence": licenceA}) p.must("licences_read_usage", map[string]any{"licence": licenceA})
p.must("licences_read_usage", map[string]any{"licence": licenceB}) p.must("licences_read_usage", map[string]any{"licence": licenceB})
m.until("no-room is raised", func() bool { return len(m.conditionsOf(noRoom)) > 0 }) m.until("no-room is raised", func() bool { return len(m.conditionsOf(noRoom)) > 0 })
@@ -1110,7 +1111,7 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) {
t.Errorf("no-room said %+v", r) t.Errorf("no-room said %+v", r)
} }
// A reading with room: the next reading of the full licence moves its consumers, and no-room is cleared. // A reading with room: the next reading of the full licence moves its consumers, and no-room is cleared.
m.vendor.set("at-a", 5, 30) m.vendor.set("at-a", 30)
p.must("licences_read_usage", map[string]any{"licence": licenceA}) p.must("licences_read_usage", map[string]any{"licence": licenceA})
p.must("licences_read_usage", map[string]any{"licence": licenceB}) p.must("licences_read_usage", map[string]any{"licence": licenceB})
m.until("no-room is cleared", func() bool { m.until("no-room is cleared", func() bool {
@@ -1159,8 +1160,8 @@ func TestANewLoginIsAdoptedListedAndATarget(t *testing.T) {
} }
// The full licence is read first: nothing has room yet, so its consumer stays. // The full licence is read first: nothing has room yet, so its consumer stays.
m.vendor.set("at-old", 99, 40) m.vendor.set("at-old", 99)
m.vendor.set("at-team", 10, 5) m.vendor.set("at-team", 5)
p.must("licences_read_usage", map[string]any{"licence": licenceA}) p.must("licences_read_usage", map[string]any{"licence": licenceA})
p.must("licences_read_usage", map[string]any{"licence": "team@example.org"}) p.must("licences_read_usage", map[string]any{"licence": "team@example.org"})
var listed []map[string]any var listed []map[string]any
+4 -4
View File
@@ -186,13 +186,13 @@
}, },
"switch_auto": { "switch_auto": {
"type": "boolean", "type": "boolean",
"description": "Whether the manager moves every consumer off a licence whose session or weekly usage reaches switch_at_pct, to the licence with the most room; off, it still raises its no-room and near-limit conditions.", "description": "Whether the manager moves every consumer off a licence whose weekly usage reaches switch_at_pct, to the licence with the most room; off, it still raises its no-room and near-limit conditions.",
"trusted": true, "trusted": true,
"default": true "default": true
}, },
"switch_at_pct": { "switch_at_pct": {
"type": "number", "type": "number",
"description": "The session or weekly usage of a licence, in percent, at which its consumers are moved to the licence with the most room.", "description": "The weekly usage of a licence, in percent, at which its consumers are moved to the licence with the most room.",
"minimum": 1, "minimum": 1,
"maximum": 100, "maximum": 100,
"trusted": true, "trusted": true,
@@ -200,7 +200,7 @@
}, },
"room_below_pct": { "room_below_pct": {
"type": "number", "type": "number",
"description": "What both the session and the weekly usage of a licence must be below, in percent, for it to be a target of a switch.", "description": "What the weekly usage of a licence must be below, in percent, for it to be a target of a switch.",
"minimum": 1, "minimum": 1,
"maximum": 100, "maximum": 100,
"trusted": true, "trusted": true,
@@ -208,7 +208,7 @@
}, },
"warn_at_pct": { "warn_at_pct": {
"type": "number", "type": "number",
"description": "The session or weekly usage of a licence with consumers, in percent, at which it is warned about once.", "description": "The weekly usage of a licence with consumers, in percent, at which it is warned about once.",
"minimum": 1, "minimum": 1,
"maximum": 100, "maximum": 100,
"trusted": true, "trusted": true,