Skip to content

Commit 032f6c5

Browse files
fix(updates): honor channel-specific availability
1 parent 6d928ba commit 032f6c5

3 files changed

Lines changed: 139 additions & 47 deletions

File tree

server/internal/domain/updates/service.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func (s *Service) GetUpdateStatus(ctx context.Context, organizationID int64) (Up
9191
status := UpdateStatus{
9292
CurrentVersion: s.currentVersion,
9393
Channel: channel,
94-
StatusAvailable: snapshot.Available,
94+
StatusAvailable: channelStatusAvailable(channel, snapshot),
9595
}
9696
if !status.StatusAvailable {
9797
return status, nil
@@ -157,13 +157,33 @@ func (s *Service) releaseChannel(ctx context.Context, organizationID int64) (Cha
157157
}
158158
}
159159

160-
// eligibleCandidate picks the channel's offer from the snapshot: stable sees
161-
// only the latest stable; stable_and_rc takes the semver max of the latest
162-
// stable and the latest RC (either side may be nil).
160+
// channelStatusAvailable reports whether the snapshot is complete enough for
161+
// the selected channel. Stable+RC can still make a useful offer from a fresh RC
162+
// when stable discovery is unavailable, but an incomplete RC view makes that
163+
// broader channel unavailable.
164+
func channelStatusAvailable(channel Channel, snap Snapshot) bool {
165+
switch channel {
166+
case ChannelStable:
167+
return snap.StableAvailable
168+
case ChannelStableAndRC:
169+
return snap.RCAvailable && (snap.StableAvailable || snap.LatestRC != nil)
170+
default:
171+
return false
172+
}
173+
}
174+
175+
// eligibleCandidate picks only verified channel candidates from the snapshot:
176+
// stable sees the latest available stable; stable_and_rc takes the semver max
177+
// of that stable and the latest available RC.
163178
func eligibleCandidate(channel Channel, snap Snapshot) *Release {
164-
candidate := snap.LatestStable
179+
var candidate *Release
180+
if snap.StableAvailable {
181+
candidate = snap.LatestStable
182+
}
165183
if channel == ChannelStableAndRC {
166-
candidate = semverMax(candidate, snap.LatestRC)
184+
if snap.RCAvailable {
185+
candidate = semverMax(candidate, snap.LatestRC)
186+
}
167187
}
168188
return candidate
169189
}

server/internal/domain/updates/service_test.go

Lines changed: 110 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,11 @@ func TestStatusStableChannelIgnoresRC(t *testing.T) {
8282
t.Parallel()
8383

8484
snaps := &fakeSnapshots{snap: Snapshot{
85-
LatestStable: rel("v0.2.8"),
86-
LatestRC: rc("v0.2.9-rc.1"),
87-
FetchedAt: testPublishedAt,
88-
Available: true,
85+
LatestStable: rel("v0.2.8"),
86+
LatestRC: rc("v0.2.9-rc.1"),
87+
FetchedAt: testPublishedAt,
88+
StableAvailable: true,
89+
RCAvailable: true,
8990
}}
9091
svc, _ := newTestService(t, "v0.2.8", snaps, newFakeChannelStore())
9192

@@ -111,10 +112,11 @@ func TestStatusStableAndRCOffersNewestRC(t *testing.T) {
111112
t.Parallel()
112113

113114
snaps := &fakeSnapshots{snap: Snapshot{
114-
LatestStable: rel("v0.2.8"),
115-
LatestRC: rc("v0.2.9-rc.1"),
116-
FetchedAt: testPublishedAt,
117-
Available: true,
115+
LatestStable: rel("v0.2.8"),
116+
LatestRC: rc("v0.2.9-rc.1"),
117+
FetchedAt: testPublishedAt,
118+
StableAvailable: true,
119+
RCAvailable: true,
118120
}}
119121
store := newFakeChannelStore()
120122
store.channels[1] = string(ChannelStableAndRC)
@@ -129,17 +131,78 @@ func TestStatusStableAndRCOffersNewestRC(t *testing.T) {
129131
assert.True(t, status.LatestEligible.Prerelease)
130132
}
131133

134+
func TestStatusRCOnlySnapshotRemainsAvailableForStableAndRC(t *testing.T) {
135+
t.Parallel()
136+
137+
snaps := &fakeSnapshots{snap: Snapshot{
138+
LatestStable: rel("v9.0.0"), // cached data whose revalidation failed
139+
LatestRC: rc("v0.2.9-rc.1"),
140+
FetchedAt: testPublishedAt,
141+
StableAvailable: false,
142+
RCAvailable: true,
143+
}}
144+
145+
stableStore := newFakeChannelStore()
146+
stableSvc, _ := newTestService(t, "v0.2.8", snaps, stableStore)
147+
stableStatus, err := stableSvc.GetUpdateStatus(context.Background(), 1)
148+
require.NoError(t, err)
149+
assert.False(t, stableStatus.StatusAvailable)
150+
assert.False(t, stableStatus.UpdateAvailable)
151+
assert.Nil(t, stableStatus.LatestEligible)
152+
153+
rcStore := newFakeChannelStore()
154+
rcStore.channels[1] = string(ChannelStableAndRC)
155+
rcSvc, _ := newTestService(t, "v0.2.8", snaps, rcStore)
156+
rcStatus, err := rcSvc.GetUpdateStatus(context.Background(), 1)
157+
require.NoError(t, err)
158+
assert.True(t, rcStatus.StatusAvailable)
159+
assert.True(t, rcStatus.UpdateAvailable)
160+
require.NotNil(t, rcStatus.LatestEligible)
161+
assert.Equal(t, "v0.2.9-rc.1", rcStatus.LatestEligible.Version,
162+
"the unverified cached stable must not outrank the fresh RC")
163+
}
164+
165+
func TestIncompleteRCViewOnlySuppressesStableAndRCChannel(t *testing.T) {
166+
t.Parallel()
167+
168+
snaps := &fakeSnapshots{snap: Snapshot{
169+
LatestStable: rel("v0.2.9"),
170+
LatestRC: rc("v9.0.0-rc.1"), // cached data whose revalidation failed
171+
FetchedAt: testPublishedAt,
172+
StableAvailable: true,
173+
RCAvailable: false,
174+
}}
175+
176+
stableSvc, _ := newTestService(t, "v0.2.8", snaps, newFakeChannelStore())
177+
stableStatus, err := stableSvc.GetUpdateStatus(context.Background(), 1)
178+
require.NoError(t, err)
179+
assert.True(t, stableStatus.StatusAvailable)
180+
assert.True(t, stableStatus.UpdateAvailable)
181+
require.NotNil(t, stableStatus.LatestEligible)
182+
assert.Equal(t, "v0.2.9", stableStatus.LatestEligible.Version)
183+
184+
rcStore := newFakeChannelStore()
185+
rcStore.channels[1] = string(ChannelStableAndRC)
186+
rcSvc, _ := newTestService(t, "v0.2.8", snaps, rcStore)
187+
rcStatus, err := rcSvc.GetUpdateStatus(context.Background(), 1)
188+
require.NoError(t, err)
189+
assert.False(t, rcStatus.StatusAvailable)
190+
assert.False(t, rcStatus.UpdateAvailable)
191+
assert.Nil(t, rcStatus.LatestEligible)
192+
}
193+
132194
// Running an RC when its stable lands must offer the stable
133195
// on BOTH channels — semver ranks v0.2.9 above v0.2.9-rc.5, and on
134196
// stable_and_rc the max-compare picks the stable over the RC.
135197
func TestStatusRCPromotedToStable(t *testing.T) {
136198
t.Parallel()
137199

138200
snaps := &fakeSnapshots{snap: Snapshot{
139-
LatestStable: rel("v0.2.9"),
140-
LatestRC: rc("v0.2.9-rc.5"),
141-
FetchedAt: testPublishedAt,
142-
Available: true,
201+
LatestStable: rel("v0.2.9"),
202+
LatestRC: rc("v0.2.9-rc.5"),
203+
FetchedAt: testPublishedAt,
204+
StableAvailable: true,
205+
RCAvailable: true,
143206
}}
144207

145208
for _, channel := range []Channel{ChannelStable, ChannelStableAndRC} {
@@ -162,10 +225,11 @@ func TestStatusNonSemverCurrentNeverOffers(t *testing.T) {
162225
t.Parallel()
163226

164227
snaps := &fakeSnapshots{snap: Snapshot{
165-
LatestStable: rel("v9.9.9"),
166-
LatestRC: rc("v9.9.10-rc.1"),
167-
FetchedAt: testPublishedAt,
168-
Available: true,
228+
LatestStable: rel("v9.9.9"),
229+
LatestRC: rc("v9.9.10-rc.1"),
230+
FetchedAt: testPublishedAt,
231+
StableAvailable: true,
232+
RCAvailable: true,
169233
}}
170234
store := newFakeChannelStore()
171235
store.channels[1] = string(ChannelStableAndRC)
@@ -190,10 +254,11 @@ func TestStatusCurrentNewerThanEveryRelease(t *testing.T) {
190254
t.Parallel()
191255

192256
snaps := &fakeSnapshots{snap: Snapshot{
193-
LatestStable: rel("v0.2.9"),
194-
LatestRC: rc("v0.2.9-rc.5"),
195-
FetchedAt: testPublishedAt,
196-
Available: true,
257+
LatestStable: rel("v0.2.9"),
258+
LatestRC: rc("v0.2.9-rc.5"),
259+
FetchedAt: testPublishedAt,
260+
StableAvailable: true,
261+
RCAvailable: true,
197262
}}
198263

199264
for _, channel := range []Channel{ChannelStable, ChannelStableAndRC} {
@@ -215,10 +280,11 @@ func TestChannelFlipDropsPendingRC(t *testing.T) {
215280
t.Parallel()
216281

217282
snaps := &fakeSnapshots{snap: Snapshot{
218-
LatestStable: rel("v0.2.8"),
219-
LatestRC: rc("v0.2.9-rc.1"),
220-
FetchedAt: testPublishedAt,
221-
Available: true,
283+
LatestStable: rel("v0.2.8"),
284+
LatestRC: rc("v0.2.9-rc.1"),
285+
FetchedAt: testPublishedAt,
286+
StableAvailable: true,
287+
RCAvailable: true,
222288
}}
223289
store := newFakeChannelStore()
224290
svc, _ := newTestService(t, "v0.2.8", snaps, store)
@@ -243,9 +309,10 @@ func TestInstallCommandExactTemplate(t *testing.T) {
243309
t.Parallel()
244310

245311
snaps := &fakeSnapshots{snap: Snapshot{
246-
LatestStable: rel("v0.2.9"),
247-
FetchedAt: testPublishedAt,
248-
Available: true,
312+
LatestStable: rel("v0.2.9"),
313+
FetchedAt: testPublishedAt,
314+
StableAvailable: true,
315+
RCAvailable: true,
249316
}}
250317
svc, _ := newTestService(t, "v0.2.8", snaps, newFakeChannelStore())
251318

@@ -334,8 +401,9 @@ func TestStatusAvailableAfterSuccessfulEmptyFetch(t *testing.T) {
334401
t.Parallel()
335402

336403
snaps := &fakeSnapshots{snap: Snapshot{
337-
FetchedAt: testPublishedAt,
338-
Available: true,
404+
FetchedAt: testPublishedAt,
405+
StableAvailable: true,
406+
RCAvailable: true,
339407
}}
340408
svc, _ := newTestService(t, "v0.2.8", snaps, newFakeChannelStore())
341409

@@ -351,9 +419,10 @@ func TestUnavailableStatusDoesNotOfferCachedRelease(t *testing.T) {
351419
t.Parallel()
352420

353421
snaps := &fakeSnapshots{snap: Snapshot{
354-
LatestStable: rel("v0.2.9"),
355-
FetchedAt: testPublishedAt,
356-
Available: false,
422+
LatestStable: rel("v0.2.9"),
423+
FetchedAt: testPublishedAt,
424+
StableAvailable: false,
425+
RCAvailable: false,
357426
}}
358427
svc, _ := newTestService(t, "v0.2.8", snaps, newFakeChannelStore())
359428

@@ -393,9 +462,10 @@ func TestStatusPropagatesChannelReadError(t *testing.T) {
393462
t.Parallel()
394463

395464
snaps := &fakeSnapshots{snap: Snapshot{
396-
LatestStable: rel("v0.2.9"),
397-
FetchedAt: testPublishedAt,
398-
Available: true,
465+
LatestStable: rel("v0.2.9"),
466+
FetchedAt: testPublishedAt,
467+
StableAvailable: true,
468+
RCAvailable: true,
399469
}}
400470
store := newFakeChannelStore()
401471
store.getErr = errors.New("connection reset by peer")
@@ -429,10 +499,11 @@ func TestNoncanonicalCandidateNeverOffered(t *testing.T) {
429499

430500
snaps := &fakeSnapshots{snap: Snapshot{
431501
// "0.3.0" (no leading v) is invalid for golang.org/x/mod/semver.
432-
LatestStable: rel("0.3.0"),
433-
LatestRC: rc("nightly-20260727-abc"),
434-
FetchedAt: testPublishedAt,
435-
Available: true,
502+
LatestStable: rel("0.3.0"),
503+
LatestRC: rc("nightly-20260727-abc"),
504+
FetchedAt: testPublishedAt,
505+
StableAvailable: true,
506+
RCAvailable: true,
436507
}}
437508
for _, channel := range []Channel{ChannelStable, ChannelStableAndRC} {
438509
store := newFakeChannelStore()

server/internal/handlers/updates/handler_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@ func TestGetUpdateStatusMapsFields(t *testing.T) {
6363
NotesURL: "https://github.com/block/proto-fleet/releases/tag/v0.2.9",
6464
PublishedAt: testPublishedAt,
6565
},
66-
FetchedAt: testPublishedAt,
67-
Available: true,
66+
FetchedAt: testPublishedAt,
67+
StableAvailable: true,
68+
RCAvailable: true,
6869
}}
6970
h := NewHandler(newTestService(t, "v0.2.8", snaps, newFakeChannelStore()))
7071
ctx := handlerstest.CtxWithPermissions(t, 7, authz.PermInstanceUpdate)

0 commit comments

Comments
 (0)