0.2.45 - Server side git commits
This commit is contained in:
@@ -521,6 +521,12 @@ type updatePolicyRequest struct {
|
||||
// toggle rather than exposing "minimum version" directly, because "force this
|
||||
// update" is the decision an operator actually wants to make.
|
||||
Required bool `json:"required"`
|
||||
// Destructive removes sessions for clients below this release. It implies Required,
|
||||
// but remains separate so a required update can keep viewers signed in.
|
||||
Destructive bool `json:"destructive"`
|
||||
// RetireBelowVersion exposes the exact destructive compatibility floor for releases
|
||||
// where the operator needs to retire only part of the installed fleet.
|
||||
RetireBelowVersion string `json:"retireBelowVersion"`
|
||||
// MinimumVersion is honoured when set explicitly, for staged rollouts where the
|
||||
// forced floor is older than the latest build.
|
||||
MinimumVersion string `json:"minimumVersion"`
|
||||
@@ -533,14 +539,15 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
policy := appupdate.Policy{
|
||||
Enabled: req.Enabled,
|
||||
LatestVersion: strings.TrimSpace(req.LatestVersion),
|
||||
MinimumVersion: strings.TrimSpace(req.MinimumVersion),
|
||||
DownloadURL: strings.TrimSpace(req.DownloadURL),
|
||||
Notes: strings.TrimSpace(req.Notes),
|
||||
}
|
||||
current := s.updatePolicy.get()
|
||||
policy := appupdate.Policy{
|
||||
Enabled: req.Enabled,
|
||||
LatestVersion: strings.TrimSpace(req.LatestVersion),
|
||||
MinimumVersion: strings.TrimSpace(req.MinimumVersion),
|
||||
RetireBelowVersion: strings.TrimSpace(req.RetireBelowVersion),
|
||||
DownloadURL: strings.TrimSpace(req.DownloadURL),
|
||||
Notes: strings.TrimSpace(req.Notes),
|
||||
}
|
||||
if policy.LatestVersion == current.LatestVersion && policy.DownloadURL == current.DownloadURL {
|
||||
// Changing "required" or release notes must not silently discard integrity
|
||||
// metadata added by the signed release publisher.
|
||||
@@ -554,6 +561,10 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request)
|
||||
// Un-ticking the box must actually release the floor.
|
||||
policy.MinimumVersion = ""
|
||||
}
|
||||
if req.Destructive {
|
||||
policy.MinimumVersion = policy.LatestVersion
|
||||
policy.RetireBelowVersion = policy.LatestVersion
|
||||
}
|
||||
|
||||
if policy.Enabled && policy.LatestVersion == "" {
|
||||
writeError(w, http.StatusBadRequest, "set the latest version before enabling update prompts")
|
||||
@@ -563,6 +574,15 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusBadRequest, "set the APK download URL before enabling update prompts")
|
||||
return
|
||||
}
|
||||
if policy.RetireBelowVersion != "" && !releaseVersionPattern.MatchString(policy.RetireBelowVersion) {
|
||||
writeError(w, http.StatusBadRequest, "the destructive update floor must look like 0.2.44")
|
||||
return
|
||||
}
|
||||
if policy.Enabled && policy.RetireBelowVersion != "" &&
|
||||
appupdate.CompareVersions(policy.RetireBelowVersion, policy.LatestVersion) > 0 {
|
||||
writeError(w, http.StatusBadRequest, "the destructive update floor cannot be newer than the latest version")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
|
||||
s.loggerFor(r.Context()).Error("update policy write failed", "error", err)
|
||||
|
||||
@@ -17,11 +17,20 @@
|
||||
<label class="field"><span>What's new</span>
|
||||
<em>Shown on the television above the update button.</em>
|
||||
<input type="text" id="update-notes" placeholder="One line the viewer reads"></label>
|
||||
<label class="field"><span>Sign out builds below</span>
|
||||
<em>The destructive compatibility floor. Leave blank to keep every supported viewer
|
||||
signed in.</em>
|
||||
<input type="text" id="update-retire-below" placeholder="0.2.44"></label>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="update-required">
|
||||
<span>Require this update<em>Blocks the home screen on every television below this
|
||||
version.</em></span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="update-destructive">
|
||||
<span>Set the destructive floor to this update<em>Deletes sessions on every older
|
||||
television when it next uses Memby, then shows the required update screen.</em></span>
|
||||
</label>
|
||||
<div class="card-foot">
|
||||
<button class="primary" id="update-save">Save policy</button>
|
||||
<button id="update-disable">Turn prompts off</button>
|
||||
|
||||
@@ -5,16 +5,20 @@ Admin.onStatus((status) => {
|
||||
// "Required" is not a field of its own: it is the minimum and the latest being the same
|
||||
// version, which is what the client compares against.
|
||||
const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion;
|
||||
const destructive = Boolean(policy.retireBelowVersion) &&
|
||||
policy.retireBelowVersion === policy.latestVersion;
|
||||
|
||||
$('update-state').innerHTML = !policy.enabled
|
||||
? ui.tag('off', 'idle')
|
||||
: ui.tag((required ? 'required · ' : 'optional · ') + policy.latestVersion,
|
||||
required ? 'warn' : 'ok');
|
||||
: ui.tag((destructive ? 'sign-out · ' : required ? 'required · ' : 'optional · ') +
|
||||
policy.latestVersion, required ? 'warn' : 'ok');
|
||||
|
||||
Admin.fill($('update-version'), policy.latestVersion || '');
|
||||
Admin.fill($('update-url'), policy.downloadUrl || '');
|
||||
Admin.fill($('update-notes'), policy.notes || '');
|
||||
Admin.fill($('update-retire-below'), policy.retireBelowVersion || '');
|
||||
Admin.check($('update-required'), required);
|
||||
Admin.check($('update-destructive'), destructive);
|
||||
});
|
||||
|
||||
const body = (enabled) => JSON.stringify({
|
||||
@@ -23,12 +27,25 @@ const body = (enabled) => JSON.stringify({
|
||||
downloadUrl: $('update-url').value.trim(),
|
||||
notes: $('update-notes').value.trim(),
|
||||
required: $('update-required').checked,
|
||||
destructive: $('update-destructive').checked,
|
||||
retireBelowVersion: $('update-retire-below').value.trim(),
|
||||
});
|
||||
|
||||
Admin.ready(() => {
|
||||
$('update-destructive').addEventListener('change', () => {
|
||||
if ($('update-destructive').checked) {
|
||||
$('update-required').checked = true;
|
||||
$('update-retire-below').value = $('update-version').value.trim();
|
||||
} else if ($('update-retire-below').value.trim() === $('update-version').value.trim()) {
|
||||
$('update-retire-below').value = '';
|
||||
}
|
||||
});
|
||||
$('update-save').addEventListener('click', () => {
|
||||
if ($('update-required').checked && !confirm('Required updates block the home screen on ' +
|
||||
'every television below this version. Continue?')) return;
|
||||
const destructive = $('update-destructive').checked;
|
||||
const warning = destructive
|
||||
? 'This will delete sessions on every older television and force viewers to sign in again after updating. Continue?'
|
||||
: 'Required updates block the home screen on every television below this version. Continue?';
|
||||
if ($('update-required').checked && !confirm(warning)) return;
|
||||
Admin.act(() => Admin.api('/admin/api/update-policy', { method: 'POST', body: body(true) }));
|
||||
});
|
||||
$('update-disable').addEventListener('click', () =>
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
||||
"github.com/ponzischeme89/memby/server/internal/bazarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
@@ -269,8 +270,10 @@ func (s *Server) authed(h authedFunc) http.Handler {
|
||||
}
|
||||
sess = s.captureClientIdentity(r, sess)
|
||||
identify(r.Context(), sess)
|
||||
decision := s.updateDecision(r)
|
||||
if mustRetireForUpdate(decision, clientVersion(r)) {
|
||||
policy := s.updatePolicy.get()
|
||||
decision := appupdate.Decide(effectiveUpdatePolicy(policy), clientVersion(r))
|
||||
retireBelow := destructiveUpdateFloor(policy)
|
||||
if mustRetireForUpdate(decision, clientVersion(r), retireBelow) {
|
||||
// Mirror an ordinary sign-out closely enough that this token cannot be restored
|
||||
// from either database or Redis. The 401 is intentional: every supported client
|
||||
// treats it as authoritative and removes the rejected local profile.
|
||||
@@ -282,7 +285,7 @@ func (s *Server) authed(h authedFunc) http.Handler {
|
||||
s.loggerFor(r.Context()).Info("signed out for required update",
|
||||
"device_id", sess.DeviceID,
|
||||
"from", clientLogValue(clientVersion(r)),
|
||||
"minimum", forcedUpdateFloor,
|
||||
"minimum", retireBelow,
|
||||
"to", decision.Version,
|
||||
)
|
||||
w.Header().Set("X-Memby-Update-Required", decision.Version)
|
||||
|
||||
@@ -161,13 +161,14 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
policy := appupdate.Policy{
|
||||
Enabled: true,
|
||||
LatestVersion: version,
|
||||
MinimumVersion: current.MinimumVersion,
|
||||
DownloadURL: s.cfg.PublicURL + s.signedReleasePath(filename),
|
||||
SHA256: actualSHA256,
|
||||
SizeBytes: written,
|
||||
Notes: strings.TrimSpace(r.FormValue("notes")),
|
||||
Enabled: true,
|
||||
LatestVersion: version,
|
||||
MinimumVersion: current.MinimumVersion,
|
||||
RetireBelowVersion: current.RetireBelowVersion,
|
||||
DownloadURL: s.cfg.PublicURL + s.signedReleasePath(filename),
|
||||
SHA256: actualSHA256,
|
||||
SizeBytes: written,
|
||||
Notes: strings.TrimSpace(r.FormValue("notes")),
|
||||
}
|
||||
if mandatory {
|
||||
// Setting the floor to the release being published makes every older client
|
||||
|
||||
@@ -17,12 +17,6 @@ import (
|
||||
// speaks, next to the build's own version.
|
||||
const ProtocolVersion = 1
|
||||
|
||||
// forcedUpdateFloor retires builds whose update behaviour is no longer reliable enough
|
||||
// to leave optional. The floor only takes effect once an enabled policy points at this
|
||||
// version (or a newer one) and carries a download URL, so deploying the gateway before
|
||||
// publishing the APK cannot lock televisions out.
|
||||
const forcedUpdateFloor = "0.2.44"
|
||||
|
||||
// updatePolicyCache keeps the policy in memory. It is read on every home request, and a
|
||||
// database round trip per home load to answer "nothing to say" would be wasteful.
|
||||
type updatePolicyCache struct {
|
||||
@@ -96,16 +90,30 @@ func compatibilityFor(r *http.Request) (bool, string) {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// effectiveUpdatePolicy applies the server-owned emergency floor without weakening a
|
||||
// higher minimum the operator has already selected.
|
||||
// destructiveUpdateFloor returns the operator-selected compatibility floor once an
|
||||
// actionable release at or above it exists. This keeps a policy saved ahead of its APK
|
||||
// from locking televisions out.
|
||||
func destructiveUpdateFloor(policy appupdate.Policy) string {
|
||||
if !policy.Enabled || strings.TrimSpace(policy.DownloadURL) == "" {
|
||||
return ""
|
||||
}
|
||||
floor := strings.TrimSpace(policy.RetireBelowVersion)
|
||||
if floor == "" || appupdate.CompareVersions(policy.LatestVersion, floor) < 0 {
|
||||
return ""
|
||||
}
|
||||
return floor
|
||||
}
|
||||
|
||||
// effectiveUpdatePolicy applies the destructive floor without weakening a higher
|
||||
// non-destructive minimum the operator has already selected.
|
||||
func effectiveUpdatePolicy(policy appupdate.Policy) appupdate.Policy {
|
||||
if !policy.Enabled || strings.TrimSpace(policy.DownloadURL) == "" ||
|
||||
appupdate.CompareVersions(policy.LatestVersion, forcedUpdateFloor) < 0 {
|
||||
floor := destructiveUpdateFloor(policy)
|
||||
if floor == "" {
|
||||
return policy
|
||||
}
|
||||
if strings.TrimSpace(policy.MinimumVersion) == "" ||
|
||||
appupdate.CompareVersions(policy.MinimumVersion, forcedUpdateFloor) < 0 {
|
||||
policy.MinimumVersion = forcedUpdateFloor
|
||||
appupdate.CompareVersions(policy.MinimumVersion, floor) < 0 {
|
||||
policy.MinimumVersion = floor
|
||||
}
|
||||
return policy
|
||||
}
|
||||
@@ -116,10 +124,10 @@ func (s *Server) updateDecision(r *http.Request) appupdate.Decision {
|
||||
|
||||
// mustRetireForUpdate is narrower than "mandatory": an operator may temporarily force a
|
||||
// newer release without wanting every otherwise supported session destroyed. Only builds
|
||||
// below the permanent compatibility floor are signed out.
|
||||
func mustRetireForUpdate(decision appupdate.Decision, version string) bool {
|
||||
// below the active destructive floor are signed out.
|
||||
func mustRetireForUpdate(decision appupdate.Decision, version, floor string) bool {
|
||||
return decision.Status == appupdate.StatusMandatory && decision.DownloadURL != "" &&
|
||||
appupdate.CompareVersions(version, forcedUpdateFloor) < 0
|
||||
strings.TrimSpace(floor) != "" && appupdate.CompareVersions(version, floor) < 0
|
||||
}
|
||||
|
||||
// requireSupportedClient prevents a retired build from signing straight back in after
|
||||
@@ -127,8 +135,9 @@ func mustRetireForUpdate(decision appupdate.Decision, version string) bool {
|
||||
// available and will keep returning the actionable mandatory verdict.
|
||||
func (s *Server) requireSupportedClient(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
decision := s.updateDecision(r)
|
||||
if mustRetireForUpdate(decision, clientVersion(r)) {
|
||||
policy := s.updatePolicy.get()
|
||||
decision := appupdate.Decide(effectiveUpdatePolicy(policy), clientVersion(r))
|
||||
if mustRetireForUpdate(decision, clientVersion(r), destructiveUpdateFloor(policy)) {
|
||||
w.Header().Set("X-Memby-Update-Required", decision.Version)
|
||||
writeJSON(w, http.StatusUpgradeRequired, decision)
|
||||
return
|
||||
|
||||
@@ -67,12 +67,13 @@ func TestUpdateOfferLogNamesTheAffectedViewer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmergencyFloorForcesClientsBelow0244(t *testing.T) {
|
||||
func TestConfiguredFloorForcesClientsBelow0244(t *testing.T) {
|
||||
server := testServer(config.Config{})
|
||||
server.updatePolicy.set(appupdate.Policy{
|
||||
Enabled: true,
|
||||
LatestVersion: "0.2.44",
|
||||
DownloadURL: "/updates/memby-0.2.44.apk?token=signed",
|
||||
Enabled: true,
|
||||
LatestVersion: "0.2.44",
|
||||
RetireBelowVersion: "0.2.44",
|
||||
DownloadURL: "/updates/memby-0.2.44.apk?token=signed",
|
||||
})
|
||||
|
||||
for version, want := range map[string]string{
|
||||
@@ -95,33 +96,72 @@ func TestEmergencyFloorForcesClientsBelow0244(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmergencyFloorWaitsForAnActionableRelease(t *testing.T) {
|
||||
func TestConfiguredFloorWaitsForAnActionableRelease(t *testing.T) {
|
||||
for name, policy := range map[string]appupdate.Policy{
|
||||
"disabled": {
|
||||
LatestVersion: "0.2.44", DownloadURL: "/updates/memby-0.2.44.apk",
|
||||
LatestVersion: "0.2.44", RetireBelowVersion: "0.2.44",
|
||||
DownloadURL: "/updates/memby-0.2.44.apk",
|
||||
},
|
||||
"missing download": {
|
||||
Enabled: true, LatestVersion: "0.2.44",
|
||||
Enabled: true, LatestVersion: "0.2.44", RetireBelowVersion: "0.2.44",
|
||||
},
|
||||
"release too old": {
|
||||
Enabled: true, LatestVersion: "0.2.43", DownloadURL: "/updates/memby-0.2.43.apk",
|
||||
Enabled: true, LatestVersion: "0.2.43", RetireBelowVersion: "0.2.44",
|
||||
DownloadURL: "/updates/memby-0.2.43.apk",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
decision := appupdate.Decide(effectiveUpdatePolicy(policy), "0.2.43")
|
||||
if mustRetireForUpdate(decision, "0.2.43") {
|
||||
t.Fatal("client would be retired without an actionable 0.2.44-or-newer release")
|
||||
if mustRetireForUpdate(decision, "0.2.43", destructiveUpdateFloor(policy)) {
|
||||
t.Fatal("client would be retired without an actionable release at the configured floor")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedDestructiveFloorRetiresClientsBelowRelease(t *testing.T) {
|
||||
policy := appupdate.Policy{
|
||||
Enabled: true,
|
||||
LatestVersion: "0.3.0",
|
||||
RetireBelowVersion: "0.3.0",
|
||||
DownloadURL: "/updates/memby-0.3.0.apk?token=signed",
|
||||
}
|
||||
|
||||
for version, want := range map[string]bool{
|
||||
"0.2.99": true,
|
||||
"0.3.0": false,
|
||||
"0.3.1": false,
|
||||
} {
|
||||
decision := appupdate.Decide(effectiveUpdatePolicy(policy), version)
|
||||
if got := mustRetireForUpdate(decision, version, destructiveUpdateFloor(policy)); got != want {
|
||||
t.Errorf("%s: retired = %t, want %t", version, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredUpdateDoesNotRetireSupportedClient(t *testing.T) {
|
||||
policy := appupdate.Policy{
|
||||
Enabled: true,
|
||||
LatestVersion: "0.3.0",
|
||||
MinimumVersion: "0.3.0",
|
||||
DownloadURL: "/updates/memby-0.3.0.apk?token=signed",
|
||||
}
|
||||
decision := appupdate.Decide(effectiveUpdatePolicy(policy), "0.2.44")
|
||||
if decision.Status != appupdate.StatusMandatory {
|
||||
t.Fatalf("status = %q, want mandatory", decision.Status)
|
||||
}
|
||||
if mustRetireForUpdate(decision, "0.2.44", destructiveUpdateFloor(policy)) {
|
||||
t.Fatal("ordinary required update retired a supported client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetiredClientCannotSignBackIn(t *testing.T) {
|
||||
server := testServer(config.Config{})
|
||||
server.updatePolicy.set(appupdate.Policy{
|
||||
Enabled: true,
|
||||
LatestVersion: "0.2.44",
|
||||
DownloadURL: "/updates/memby-0.2.44.apk?token=signed",
|
||||
Enabled: true,
|
||||
LatestVersion: "0.2.44",
|
||||
RetireBelowVersion: "0.2.44",
|
||||
DownloadURL: "/updates/memby-0.2.44.apk?token=signed",
|
||||
})
|
||||
reached := false
|
||||
handler := server.requireSupportedClient(func(http.ResponseWriter, *http.Request) {
|
||||
|
||||
@@ -32,6 +32,10 @@ type Policy struct {
|
||||
// an old release) to keep updates optional; set it to LatestVersion to make the
|
||||
// current release mandatory for everyone.
|
||||
MinimumVersion string `json:"minimumVersion"`
|
||||
// RetireBelowVersion is the destructive floor. Clients below it have their session
|
||||
// removed before receiving the mandatory update screen. It is separate from
|
||||
// MinimumVersion so an ordinary required update does not sign viewers out.
|
||||
RetireBelowVersion string `json:"retireBelowVersion,omitempty"`
|
||||
// DownloadURL points at the APK — normally the same file the landing page serves.
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
// SHA256 and SizeBytes let the TV reject a truncated, stale or substituted download
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.31
|
||||
0.1.32
|
||||
|
||||
Reference in New Issue
Block a user