diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index f1a38739df..b9ec38638f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -4065,6 +4065,15 @@ telemetry exports only that record at schema v18. An update discovery failure that records nothing is a regression, because a failed check never reaches the update history and is otherwise invisible in the fleet (#2285). Proof: `internal/updates/issue2285_update_check_observation_test.go`. +The observation describes installability, not merely version ordering. Positive +release fixtures must include the exact server archive; a newer release that +lacks that archive is not offered and records `metadata_error`, rather than +`up_to_date` or `available`. A compiled Pro install without usable broker +activation credentials returns its existing operator warning but records +`skipped`, never `up_to_date`; this unavailable result must not be cached across +a later activation. A stable-channel Pro check against a prerelease-only broker +pin records `no_release`. No version, URL, credential or warning text enters +the telemetry observation. Those same workflows must also fetch and dispatch the governed release branch derived from release-control metadata instead of hardcoding `pulse/v6`, `pulse/v6-release`, `main`, or any later branch literal inline; when a stable diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 25bce8769b..42a4f46e5d 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,36 +1,28 @@ { "version": 1, - "base_sha": "b1e75fb9e4693c5706b2242760d6277e5291e7f9", - "verified_at": "2026-09-27T22:55:00Z", + "base_sha": "6d7e3342e56a4f8f2b411acb3df8dc7777d4ec7f", + "verified_at": "2026-09-27T23:52:13Z", "result": "passed", "changed_paths": ["frontend-modern/src/api/settings.ts"], "content_sha256": { "frontend-modern/src/api/settings.ts": "4172b94c268be6a0ea000e73d155e540f11776e1c59325250e13716722be5aeb" }, - "routes": ["/settings/system-general", "/docs/PRIVACY"], + "routes": ["/tmp/telemetry-schema-2285.html"], "viewports": [ - { - "width": 1024, - "height": 768 - }, - { - "width": 390, - "height": 844 - } + {"width": 1280, "height": 800}, + {"width": 390, "height": 844} ], "states": [ - "Local build of this branch signed in with a test account: General settings telemetry card rendered with the Preview payload control", - "Telemetry payload preview opened: schema_version 18 with update_channel stable, update_check_outcome skipped (source build, recorded by the background update checker) and update_available false, all three keys present", - "Preview at 390px: payload block visible and scrolls within its own box with no page-level horizontal overflow", - "Shipped privacy document: schema version 18 disclosure, Update channel, Update check outcome and Update available rows, and the dated schema 18 changelog row present", - "Privacy document at 390px: new rows render in the existing table, which scrolls within its container like the neighbouring rows, with no page-level horizontal overflow", - "Desktop and phone screenshots inspected for placement, clipping and overflow" + "Production GeneralSettingsPanel mounted in an isolated Vite fixture with a synthetic schema-v18 telemetry-preview API response", + "Preview payload displayed stable-channel skipped, metadata_error, and available outcomes with corresponding false, false, and true update_available booleans", + "The payload block stayed inside the card; the metadata_error JSON scrolled within the block at 390px without page-level horizontal overflow", + "Desktop and phone screenshots for all three outcomes were inspected for placement, clipping and scrolling" ], "interactions": [ - "Signed in through the login form", - "Navigated to /settings/system-general and clicked Preview payload", - "Parsed the rendered payload JSON and checked the schema 18 fields and closed values", - "Resized to 390x844 and rechecked the preview and page overflow", - "Navigated to /docs/PRIVACY and scrolled the Update check outcome row into view at desktop and 390px widths" - ] + "Clicked Preview payload and then Refresh payload twice per viewport, replacing the synthetic API outcome on each request", + "Parsed the rendered payload JSON and asserted schema_version, update_channel, update_check_outcome and update_available", + "Checked page and preview scroll widths at both viewports and checked for uncaught page errors" + ], + "command": "pulse-worker-browser tmp/browser-proof/telemetry-schema-2285.cjs (Playwright 1.56.1, Chromium 141.0.7390.37)", + "notes": "Offline production-component fixture with synthetic API data, not a full application login, installed sender, receiver or release acceptance. The browser result and six screenshots are retained under tmp/browser-proof in this assigned workspace." } diff --git a/internal/updates/issue2285_update_check_observation_test.go b/internal/updates/issue2285_update_check_observation_test.go index cc26e2bd4e..5adc15c945 100644 --- a/internal/updates/issue2285_update_check_observation_test.go +++ b/internal/updates/issue2285_update_check_observation_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "time" @@ -33,10 +34,22 @@ func issue2285Releases(t *testing.T, releases ...ReleaseInfo) string { return string(body) } +func issue2285InstallableRelease(t *testing.T, tag string, prerelease bool, published time.Time) ReleaseInfo { + t.Helper() + asset, ok := updateReleaseAssetForRuntime(tag) + if !ok { + t.Skip("no server release archive for this architecture") + } + return ReleaseInfo{ + TagName: tag, Prerelease: prerelease, PublishedAt: published, + Assets: []ReleaseAsset{asset}, + } +} + func TestIssue2285LastUpdateCheckRecordsEffectiveChannelOutcome(t *testing.T) { setRetrySettingsForTest(t, 1, time.Millisecond, time.Millisecond) - stable := ReleaseInfo{TagName: "v6.4.5", PublishedAt: time.Date(2026, 9, 30, 8, 0, 0, 0, time.UTC)} - preview := ReleaseInfo{TagName: "v6.4.6-rc.1", Prerelease: true, PublishedAt: time.Date(2026, 10, 2, 8, 0, 0, 0, time.UTC)} + stable := issue2285InstallableRelease(t, "v6.4.5", false, time.Date(2026, 9, 30, 8, 0, 0, 0, time.UTC)) + preview := issue2285InstallableRelease(t, "v6.4.6-rc.1", true, time.Date(2026, 10, 2, 8, 0, 0, 0, time.UTC)) for _, tc := range []struct { name string @@ -51,6 +64,7 @@ func TestIssue2285LastUpdateCheckRecordsEffectiveChannelOutcome(t *testing.T) { {name: "update offered", current: "6.4.1", channel: "stable", status: http.StatusOK, body: issue2285Releases(t, stable, preview), wantOutcome: UpdateCheckOutcomeAvailable, wantAvailable: true}, {name: "already current", current: "6.4.5", channel: "stable", status: http.StatusOK, body: issue2285Releases(t, stable, preview), wantOutcome: UpdateCheckOutcomeUpToDate}, {name: "preview channel offered newer prerelease", current: "6.4.5", channel: "rc", status: http.StatusOK, body: issue2285Releases(t, stable, preview), wantOutcome: UpdateCheckOutcomeAvailable, wantAvailable: true}, + {name: "newer release missing exact archive", current: "6.4.1", channel: "stable", status: http.StatusOK, body: issue2285Releases(t, ReleaseInfo{TagName: stable.TagName, Assets: []ReleaseAsset{{Name: "pulse-agent-v6.4.5-linux-amd64.tar.gz", BrowserDownloadURL: "https://example.invalid/agent"}}}), wantOutcome: UpdateCheckOutcomeMetadataError}, {name: "no stable release for channel", current: "6.4.5-rc.3", channel: "stable", status: http.StatusOK, body: issue2285Releases(t, preview), wantOutcome: UpdateCheckOutcomeNoRelease}, {name: "malformed metadata", current: "6.4.1", channel: "stable", status: http.StatusOK, body: `{"message":"not a list"}`, wantOutcome: UpdateCheckOutcomeMetadataError, wantErr: true}, {name: "server error", current: "6.4.1", channel: "stable", status: http.StatusBadGateway, body: `bad gateway`, wantOutcome: UpdateCheckOutcomeNetworkError, wantErr: true}, @@ -64,10 +78,13 @@ func TestIssue2285LastUpdateCheckRecordsEffectiveChannelOutcome(t *testing.T) { t.Fatalf("before any check LastUpdateCheck = %+v, want not_checked on %s", got, tc.channel) } - _, err := manager.CheckForUpdates(context.Background()) + info, err := manager.CheckForUpdates(context.Background()) if (err != nil) != tc.wantErr { t.Fatalf("CheckForUpdates error = %v, wantErr %v", err, tc.wantErr) } + if err == nil && (info.Available != tc.wantAvailable || (tc.wantAvailable && info.DownloadURL == "") || (tc.name == "newer release missing exact archive" && info.DownloadURL != "")) { + t.Fatalf("CheckForUpdates result = %+v, want available %v with matching archive only", info, tc.wantAvailable) + } got := manager.LastUpdateCheck() if got.Outcome != tc.wantOutcome || got.Available != tc.wantAvailable || got.Channel != tc.channel || got.CheckedAt.IsZero() { t.Fatalf("LastUpdateCheck = %+v, want outcome %s available %v on %s", got, tc.wantOutcome, tc.wantAvailable, tc.channel) @@ -80,8 +97,8 @@ func TestIssue2285PreviewOfOtherChannelDoesNotReplaceObservation(t *testing.T) { setRetrySettingsForTest(t, 1, time.Millisecond, time.Millisecond) withBuildVersion(t, "6.4.5") issue2285Server(t, http.StatusOK, issue2285Releases(t, - ReleaseInfo{TagName: "v6.4.5"}, - ReleaseInfo{TagName: "v6.4.6-rc.1", Prerelease: true}, + issue2285InstallableRelease(t, "v6.4.5", false, time.Time{}), + issue2285InstallableRelease(t, "v6.4.6-rc.1", true, time.Time{}), )) manager := NewManager(&config.Config{UpdateChannel: "stable"}) @@ -95,14 +112,61 @@ func TestIssue2285PreviewOfOtherChannelDoesNotReplaceObservation(t *testing.T) { // The settings UI can preview the preview channel before saving it. That // offer is not what this install is being offered, so it must not leak // into the observation telemetry reports. - if _, err := manager.CheckForUpdatesWithChannel(context.Background(), "rc"); err != nil { + previewInfo, err := manager.CheckForUpdatesWithChannel(context.Background(), "rc") + if err != nil { t.Fatalf("preview-channel check: %v", err) } + if !previewInfo.Available || previewInfo.DownloadURL == "" { + t.Fatalf("preview-channel check = %+v, want installable preview offer", previewInfo) + } if got := manager.LastUpdateCheck(); got.Outcome != UpdateCheckOutcomeUpToDate || got.Available || got.Channel != "stable" { t.Fatalf("after previewing rc LastUpdateCheck = %+v, want stable up_to_date unchanged", got) } } +func TestIssue2285UnactivatedProCheckIsNotUpToDateOrCached(t *testing.T) { + setupProUpdateTest(t, "6.0.0") + manager := NewManager(&config.Config{UpdateChannel: "stable", DataPath: t.TempDir()}) + manager.SetProUpdateCredentialSource(func() (ProUpdateCredentials, bool) { + return ProUpdateCredentials{}, false + }) + + info, err := manager.CheckForUpdates(context.Background()) + if err != nil { + t.Fatalf("unactivated Pro check: %v", err) + } + if info.Available || !strings.Contains(info.Warning, "Update checks are unavailable") { + t.Fatalf("unactivated Pro result = %+v, want unavailable warning without an offer", info) + } + if got := manager.LastUpdateCheck(); got.Outcome != UpdateCheckOutcomeSkipped || got.Available || got.Channel != "stable" { + t.Fatalf("unactivated Pro observation = %+v, want stable skipped without offer", got) + } + + fixture := newProBrokerFixture(t, "6.0.5", false) + manager.SetProUpdateCredentialSource(fixture.credentialSource()) + info, err = manager.CheckForUpdates(context.Background()) + if err != nil { + t.Fatalf("activated Pro check: %v", err) + } + if fixture.brokerCalls != 1 || !info.Available { + t.Fatalf("activated Pro result = %+v, broker calls = %d; want fresh offer", info, fixture.brokerCalls) + } + if got := manager.LastUpdateCheck(); got.Outcome != UpdateCheckOutcomeAvailable || !got.Available { + t.Fatalf("activated Pro observation = %+v, want available", got) + } + + previewPin := newProBrokerFixture(t, "6.1.0-rc.1", true) + stableManager := NewManager(&config.Config{UpdateChannel: "stable", DataPath: t.TempDir()}) + stableManager.SetProUpdateCredentialSource(previewPin.credentialSource()) + info, err = stableManager.CheckForUpdates(context.Background()) + if err != nil || info.Available || previewPin.brokerCalls != 1 { + t.Fatalf("stable Pro check against preview pin = %+v, err=%v, broker calls=%d", info, err, previewPin.brokerCalls) + } + if got := stableManager.LastUpdateCheck(); got.Outcome != UpdateCheckOutcomeNoRelease || got.Available { + t.Fatalf("stable Pro preview-pin observation = %+v, want no_release", got) + } +} + func TestIssue2285SourceBuildReportsSkipped(t *testing.T) { withBuildVersion(t, "6.4.0") markerPath := "BUILD_FROM_SOURCE" diff --git a/internal/updates/manager.go b/internal/updates/manager.go index 05ef73646e..68f8904697 100644 --- a/internal/updates/manager.go +++ b/internal/updates/manager.go @@ -74,6 +74,10 @@ type UpdateInfo struct { // compiled Pro binary, which cannot self-update in a container and must // never be pointed at the community rcourtman/pulse image. DockerUpdate *DockerUpdateCommands `json:"dockerUpdate,omitempty"` + // checkOutcome is only for the content-free observation. It distinguishes a + // check that could not run from a completed check with no update; it is not + // part of the update API response. + checkOutcome string } var ( @@ -453,8 +457,14 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update m.updateStatus("error", 0, "Failed to check for Pulse Pro updates", proErr) return nil, proErr } - m.recordUpdateCheck(channel, effectiveChannel, availabilityOutcome(info.Available), info.Available) - if useCache { + outcome := availabilityOutcome(info.Available) + if info.checkOutcome != "" { + outcome = info.checkOutcome + } + m.recordUpdateCheck(channel, effectiveChannel, outcome, info.Available) + // A missing activation can be repaired without restarting Pulse. Do not + // cache its unavailable result across the next credentialed check. + if useCache && info.checkOutcome != UpdateCheckOutcomeSkipped { m.statusMu.Lock() m.checkCache[channel] = info m.cacheTime[channel] = time.Now() @@ -566,7 +576,13 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update } info.Warning = updateWarning(info.Available, isMajorUpgrade, isPrerelease, currentVer.Major, latestVer.Major) - m.recordUpdateCheck(channel, effectiveChannel, availabilityOutcome(info.Available), info.Available) + checkOutcome := availabilityOutcome(info.Available) + if latestVer.IsNewerThan(currentVer) && downloadURL == "" { + // Metadata for a newer release without this binary's exact archive is + // not an offer and is not evidence that the install is up to date. + checkOutcome = UpdateCheckOutcomeMetadataError + } + m.recordUpdateCheck(channel, effectiveChannel, checkOutcome, info.Available) // Cache the result (only if using saved channel) if useCache { diff --git a/internal/updates/pro_update.go b/internal/updates/pro_update.go index 9eaf6b84f1..5f70d500dc 100644 --- a/internal/updates/pro_update.go +++ b/internal/updates/pro_update.go @@ -310,6 +310,7 @@ func (m *Manager) checkProUpdates(ctx context.Context, channel string, currentIn CurrentVersion: currentInfo.Version, LatestVersion: currentInfo.Version, Warning: "Update checks are unavailable: " + errProUpdateNotActivated().Error(), + checkOutcome: UpdateCheckOutcomeSkipped, }, nil } @@ -333,6 +334,7 @@ func (m *Manager) checkProUpdates(ctx context.Context, channel string, currentIn CurrentVersion: currentInfo.Version, LatestVersion: currentInfo.Version, Warning: fmt.Sprintf("The private Pulse Pro release channel currently serves prerelease %s; stable-channel installs skip prereleases.", manifest.Release.Version), + checkOutcome: UpdateCheckOutcomeNoRelease, }, nil }