From 06d78b66d3fa5d99b94fc576860ffb177d3c0dd4 Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Tue, 29 Sep 2026 13:14:14 +0100 Subject: [PATCH 1/5] Balance internal/api race shards by measured run time The four Backend tests (api-N) shards took equal-count quarters of go test's list, which left them at 4.0, 10.6, 4.0 and 16.8 minutes on the first PR run, and at 1.9, 8.0, 1.5 and 10.3 minutes of go test time on the following main push (run 36560070496). Almost all of it sits in a few tests. One contract mock chart test runs about six minutes on the runner, and the 28 integration server tests at the end of the list take about twenty seconds each, so the fourth quarter held nearly half the package. Shards are still contiguous runs of go test's own order, since some tests depend on package state from their neighbours, but the cut points now come from estimated seconds. .github/scripts/select-internal-api-shard.sh weighs each test from .github/scripts/internal-api-test-seconds.txt (the 199 tests at 0.5s or more, seeded from a local -race -json run scaled to that main run's shard totals, with a 0.05s default for anything unlisted), binary searches the smallest per-shard budget that fits the list into four slices, and fills up to it. The weights only move cut points. Every test is assigned exactly one shard in a single pass, so a stale file can unbalance shards but never drop or repeat a test, and no shard is ever empty. With today's list the slices hold 1187, 23, 2556 and 21 tests, estimated at 1.9, 6.9, 6.5 and 6.7 minutes of tests, or about 9.5 minutes per job with the roughly 2.6 minutes of setup and race compile. The 2556-test slice would need a 124 KB -run pattern, so a shard passes -skip of the other shards' tests whenever that is shorter, which runs the same tests in the same order. --- .github/scripts/internal-api-test-seconds.txt | 212 ++++++++++++++++++ .github/scripts/select-internal-api-shard.sh | 107 +++++++++ .github/workflows/build-and-test.yml | 38 +++- .../subsystems/deployment-installability.md | 21 +- .../v6/internal/subsystems/registry.json | 4 + .../installtests/build_release_assets_test.go | 99 +++++++- 6 files changed, 466 insertions(+), 15 deletions(-) create mode 100644 .github/scripts/internal-api-test-seconds.txt create mode 100755 .github/scripts/select-internal-api-shard.sh diff --git a/.github/scripts/internal-api-test-seconds.txt b/.github/scripts/internal-api-test-seconds.txt new file mode 100644 index 0000000000..d3465b3b52 --- /dev/null +++ b/.github/scripts/internal-api-test-seconds.txt @@ -0,0 +1,212 @@ +# Estimated -race run time in seconds of the slowest internal/api top-level +# tests on a GitHub ubuntu-24.04 runner. Used only by +# select-internal-api-shard.sh to choose where the Backend tests (api-N) +# shards cut go test's list. Tests not named here weigh DEFAULT_WEIGHT (0.05s, +# the measured mean of the unlisted tests). A missing, stale or renamed entry +# can make the shards uneven but can never drop or repeat a test. +# +# Refresh when one api shard runs well over the others: run +# GOMAXPROCS=4 go test -race -json ./internal/api +# and list every top-level test at 0.5s or more. Seeded 2026-09-29 from such a +# local run scaled to the four shard totals of Build and Test run 36560070496 +# (the contract mock chart test runs about 1.4x slower on the runner, the +# wait-bound integration server tail about 0.8x). +TestActionRunnerRotationProductionRouterTLSPersistenceRestart 0.69 +TestMockActionInboxAndDetailUseCanonicalResponses 2.40 +TestMockActionInboxHonorsLimitAndLegacyPendingProjection 1.79 +TestMockActionMutationsFailReadOnly 0.98 +TestAgentReportHandlersClassifyBodyFailures 1.10 +TestStart_MockModeStartsChatServiceWhenPersistedConfigDisabled 0.95 +TestRestart_MockModeStartsChatServiceWhenPersistedConfigDisabled 0.95 +TestHandleChat_EmitsIdleProgressWhileServiceIsSilent 2.40 +TestAISettingsHandler_ProviderSettersConcurrentServiceCreation 8.31 +TestHandleSaveGuestNote_VerifySavedFields 0.52 +TestHandleClearGuestKnowledge_OversizedBody 0.57 +TestHandlePatrolStream_LastEventIDHeaderTakesPrecedenceOverQueryFallback 1.99 +TestHandleExecuteStream_LicenseRequired 1.05 +TestHandleExecuteStream_PromptRequired 1.06 +TestHandleExecuteStream_Success 1.80 +TestHandleExecuteStream_EmitsIdleProgressWhileServiceIsSilent 1.74 +TestHandleInvestigateAlert_EmitsIdleProgressWhileServiceIsSilent 1.69 +TestAISettingsHandler_PatrolReadinessBranches 0.85 +TestHandleInvestigateAlert_UsesVisibleIdleProgressStream 1.73 +TestHandleGetPatterns_UnlockedWithData 1.04 +TestHandleGetPredictions_WithData 1.04 +TestHandleGetCorrelations_WithData 1.05 +TestHandleGetCorrelations_UsesCanonicalIntelligenceFacade 1.04 +TestExtractAndStoreAuthContext_UsesTenantConfigForToken 2.98 +TestAuthDenialPruneKeepsTrackedSetBounded 1.92 +TestAvailabilityHandlersListReturnsMockTargetsInMockMode 1.03 +TestAvailabilityHandlersTestSavedMockTargetUsesSyntheticStatus 1.03 +TestChangePasswordRefusesNonAdminSession 0.83 +TestChangePasswordAllowsConfiguredAdminSession 1.52 +TestChangePasswordBasicAuthPathUnaffected 4.19 +TestChangePasswordFixtureOwnsRouterLifecycle 0.84 +TestConfigTransferAnonymousAuthenticatedModesDenyBeforeBodyRead 0.50 +TestConfigTransferAuthorizedInstanceModesReachHandler 0.70 +TestConnectionsHandleListIncludesMockAvailabilityTargets 1.13 +TestConnectionsHandleListDropsRealSourcesInMockMode 1.19 +TestConnectionsLedgerKeepsRealSourcesWhenRealPollingRetained 1.15 +TestContract_MockAvailabilityTargetsUseSavedTargetPayloads 1.13 +TestContract_MockDiscoveryEndpointsUseCanonicalPayloads 1.05 +TestContract_MockChartRoutesUseCanonicalMockUnifiedReadStateForVMwareHosts 391.48 +TestContract_PlatformMockToggleRebindsRuntimeConnectionsAndResources 19.96 +TestContract_PlatformMockConnectionListsUseSharedFixtureMetadata 1.08 +TestContract_RecoveryPointsMockPathReturnsCanonicalProviderBackedFixtures 1.61 +TestContract_SecurityStatusPresentationPolicyProEditionCommercialContext 0.50 +TestContract_StateAndResourceListShareCanonicalMockResourceContract 4.64 +TestContract_AgentCommandConfigGateMirrorsChannelAdmission 1.13 +TestContract_HostedDiagnosticsDockerPrepareTokenValidatesOriginBeforeMutation 1.22 +TestContract_HostedInstallerOriginsFailClosedAtRouter 4.04 +TestContract_RuntimeDisplayServesPresentationValuesWithoutAdmin 0.84 +TestSecurityStatusCurrentUserForScopedLocalSession 0.84 +TestSecurityStatusOrgBoundSettingsFollowOrgManagementRule 0.84 +TestSecurityStatusOrgBoundReportingRespectsAuthorizer 0.83 +TestDiscoveryHandlers_MockModeServesFixturesWithoutService 1.13 +TestDockerAgentReportSizeContractAcceptsOrdinaryAndLargeFleets 0.55 +TestDockerAgentReportSizeContractBoundaries 2.57 +TestCollectorUninstallTransactionFailsClosedAndRetriesAfterRestart 2.15 +TestHostedTenantAgentInstallCommand_GeneratesOrgBoundTokenAndCommand 3.13 +TestGenerateHostedTenantAgentInstallCommandEnforcesHostedOrgBoundary 1.11 +TestHostedTenantAgentInstallCommandCarriesCommandPolicyIntent 1.49 +TestHostedTenantAgentInstallTokenCannotReportToOtherTenant 0.50 +TestIssue1753SameNameProxmoxAgentsAuthenticateAndStayDistinctEndToEnd 2.20 +TestSyncReleaseDemoFixtureRuntime_DoesNotPoliceMockFixturesInDevBuilds 1.23 +TestMetricsHistoryFallbackSynthesizesUnseededMockWorkloadHistory 1.64 +TestMetricsHistoryFallbackSynthesizesMockHostAndNodeDrawerSeries 1.99 +TestMetricsHistoryFallbackSupplementsPartialMockStoreSeries 1.68 +TestMetricsHistoryFallbackMockDiskSynthesizesSeries 1.65 +TestMetricsHistoryFallbackMockUnifiedStorageSynthesizesSeries 2.27 +TestMetricsHistoryFallbackMockStorageBypassesSparseStoreSeries 2.47 +TestSSOOnlyExplicitAdminReachesEveryAdminGuard 1.42 +TestUnrelatedSSOUserStillRefusedWhenLocalAdminConfigured 2.10 +TestPatrolActionBrokerAutonomyModeIsAnUpperBound 0.53 +TestPlatformAdminRouteRefusesUnrelatedSSOUser 0.84 +TestJourneyGeneralAPIBudgetIsolation 1.75 +TestTenantRBACProvider_ConcurrentGetAndRemove 0.52 +TestTenantRBACProvider_NoConnectionLeak 1.43 +TestHandleListPointsAcceptsCanonicalPlatformQuery 1.82 +TestHandleListRollupsExposeCanonicalPlatformsPayload 1.86 +TestHandleListRollupsMetaReportsNormalizedPagination 2.71 +TestHandleListPointsMetaReportsNormalizedPagination 2.74 +TestStartPatrolForContext_DoesNotOverwriteOtherTenantPatrolComponents 2.30 +TestStartPatrolForContext_RejectsMismatchedAIServiceOrg 1.24 +TestRouterMockMode_SeedsTrueNASAndVMwareSupplementalResources 1.58 +TestRouterMockMode_SeedsVMwareSupplementalActivity 1.21 +TestRouteExecuteStream_Success 0.94 +TestRouteExecuteStream_MethodNotAllowed 0.76 +TestRouteExecuteStream_NoAuth 0.60 +TestRouteExecuteStream_WrongScope 0.65 +TestRouteExecuteStream_EmptyPrompt 0.83 +TestRouteExecuteStream_WhitespaceOnlyPrompt 0.73 +TestRouteExecuteStream_InvalidJSON 0.80 +TestRouteExecuteStream_AIDisabled 0.78 +TestRouteExecuteStream_AutofixLicenseRequired 0.75 +TestRouteExecuteStream_RemediationLicenseRequired 0.66 +TestRouteExecuteStream_OversizedBody 0.82 +TestRouteExecuteStream_UseCaseCaseInsensitive 0.86 +TestRouteExecuteStream_SSEHeaders 0.66 +TestRouteExecuteStream_CompleteEventBeforeDone 0.76 +TestRouteExecuteStream_EmptyBody 0.68 +TestRouteExecuteStream_NullPrompt 0.74 +TestRouteExecuteStream_EmptyJSONObject 0.55 +TestRouteExecuteStream_ServiceError 0.69 +TestRouteExecuteStream_InvalidTargetType 0.60 +TestRouteExecuteStream_InvalidHistoryRole 0.70 +TestRouteExecuteStream_TargetIDTooLong 0.58 +TestRouteExecuteStream_ValidTargetTypes 2.08 +TestRouteExecuteStream_EmptyTargetTypeAllowed 1.50 +TestRouteExecuteStream_WithConversationHistory 0.58 +TestRouteExecuteStream_WithTargetContext 0.75 +TestRouteExecuteStream_DefaultUseCaseIsChat 0.73 +TestRouteExecuteStream_FindingIDPassedThrough 0.73 +TestRouteExecuteStream_ModelFieldPassedThrough 0.63 +TestRouteExecute_Success 0.74 +TestRouteExecute_MethodNotAllowed 0.75 +TestRouteExecute_NoAuth 0.79 +TestRouteExecute_WrongScope 0.69 +TestRouteExecute_EmptyPrompt 0.74 +TestRouteExecute_InvalidJSON 0.75 +TestRouteExecute_WithConversationHistory 0.68 +TestRouteExecute_WithTargetContext 0.87 +TestRouteExecute_DefaultUseCaseIsChat 0.73 +TestRouteExecute_AutofixLicenseRequired 0.75 +TestRouteExecute_RemediationLicenseRequired 0.56 +TestRouteExecute_OversizedBody 0.93 +TestRouteExecute_EmptyBody 0.66 +TestRouteExecute_EmptyJSONObject 0.94 +TestRouteExecute_NullPrompt 0.75 +TestRouteExecute_ServiceError 0.73 +TestRouteExecute_InvalidTargetType 0.75 +TestRouteExecute_ValidTargetTypes 2.17 +TestRouteExecute_EmptyTargetTypeAllowed 0.75 +TestRouteExecute_InvalidHistoryRole 0.73 +TestRouteExecute_FindingIDPassedThrough 0.76 +TestRouteExecute_ModelFieldPassedThrough 0.73 +TestRouteListModels_Success 0.68 +TestRouteListModels_MethodNotAllowed 0.66 +TestRouteListModels_WrongScope 0.72 +TestRouteListModels_ProviderError 0.75 +TestRouteListModels_EmptyModelList 0.76 +TestRouteListModels_ResponseContentType 0.63 +TestRouteListModels_CachedField 0.76 +TestRouteListModels_MalformedProviderResponse 0.78 +TestRouteListModels_HEADMethod 0.87 +TestRouteTestConnection_Success 0.76 +TestRouteTestConnection_MethodNotAllowed 0.72 +TestRouteTestConnection_NoAuth 0.74 +TestRouteTestConnection_WrongScope 0.74 +TestRouteTestProvider_Success 0.72 +TestRouteTestProvider_UnconfiguredProvider 0.66 +TestRouteTestProvider_ConnectionFailure 0.60 +TestRouteTestProvider_MethodNotAllowed 0.76 +TestRouteTestProvider_NoAuth 0.75 +TestRouteTestProvider_MultipleProviders 0.74 +TestRouteTestProvider_UnknownProvider 0.60 +TestRouteTestProvider_WrongScope 0.75 +TestRouteTestProvider_InvalidProviderNames 0.74 +TestRouteTestProvider_PathTraversal 0.85 +TestRouter_HandleState_MockIsolation 4.14 +TestAITestConnectionRouteWithValidScope 0.75 +TestAITestConnectionRouteRejectsWrongScope 0.82 +TestAITestProviderRouteRejectsWrongScope 0.74 +TestPublicEndpointsBypassAuthInAPIMode 0.61 +TestSettingsCapabilitiesMatchRouteEnforcementWithoutRBAC 0.86 +TestSettingsCapabilitiesGrantConfiguredAdminWithoutRBAC 0.93 +TestTrueNASHandlers_HandleList_ReturnsMockConnectionsInMockMode 1.23 +TestFreeBSDOfflineAgentPreflight 1.83 +TestBranchcov0723AmExtractFromZip 8.86 +TestHandleApplyUpdate_BlocksWhenReadinessBlocked 1.47 +TestHandleGetUpdatePlan 1.65 +TestHandleGetUpdatePlan_IncludesUpgradeReadiness 1.34 +TestHandleGetUpdatePlan_InvalidChannel 1.09 +TestHandleGetUpdatePlan_PrepareError 1.43 +TestVMwareHandlers_HandleList_ReturnsMockConnectionsInMockMode 1.33 +TestAlertsEndpoints 20.56 +TestRecoveryRollupsEndpointToleratesMalformedPersistedMetadata 0.55 +TestHealthEndpoint 19.04 +TestVersionEndpointUsesRepoVersion 19.47 +TestAlertAcknowledge_AllowsPrintableAlertIDs 18.81 +TestStateEndpointReturnsMockData 20.72 +TestRecoveryRollupsEndpointReturnsMockData 20.65 +TestProtectedEndpointsRequireAuthentication 18.62 +TestAPIOnlyModeRequiresToken 21.79 +TestServerInfoEndpointReportsDevelopment 18.85 +TestRecoveryPointsEndpointReturnsMockData 21.75 +TestServerInfoEndpointMethodNotAllowed 18.65 +TestHealthEndpointMethodNotAllowed 18.76 +TestHealthEndpointHeadAllowed 18.68 +TestConfigNodesUsesMockTopology 17.84 +TestMockModeToggleEndpoint 25.43 +TestMockModeToggleFromRealDataRebindsPlatformSupplementalProviders 19.24 +TestAuthenticatedEndpointsRequireToken 19.40 +TestAPITokenQueryAndHeaderAuth 23.11 +TestRecoveryEndpointRequiresDirectLoopback 20.32 +TestWebSocketSendsInitialState 19.38 +TestWebsocketPayloadContractShape 17.62 +TestWebsocketPayloadUsesCanonicalStateContract 19.56 +TestSessionCookieAllowsAuthenticatedAccess 20.16 +TestRevokedAPITokenImmediatelyLosesAccess 24.33 +TestLimitedAPITokenCannotCreateBroaderToken 20.31 +TestPublicURLDetectionUsesForwardedHeaders 17.31 +TestPublicURLDetectionRespectsEnvOverride 19.14 diff --git a/.github/scripts/select-internal-api-shard.sh b/.github/scripts/select-internal-api-shard.sh new file mode 100755 index 0000000000..3a0cf0cd4f --- /dev/null +++ b/.github/scripts/select-internal-api-shard.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Print the internal/api top-level tests that belong to one CI shard. +# +# Usage: select-internal-api-shard.sh +# The full test list, in go test's own order, is read from stdin. +# +# Shards are contiguous runs of go test's order, because some internal/api +# tests still depend on package state left by the tests just before them. +# Cut points are chosen by estimated run time instead of test count. Every +# test weighs its seconds from the weights file, or DEFAULT_WEIGHT when the +# file does not name it (new tests, renamed tests). The smallest per-shard +# capacity that lets the list be cut into at most contiguous +# runs is found by binary search, then the list is filled greedily up to that +# capacity. If fewer runs than shards would result, the tail is cut early so +# every shard still gets at least one test. +# +# The weights only move the cut points. Each test is assigned exactly one +# shard index in 0..count-1 by a single pass over the list, so a stale, +# missing or wrong weight can make shards uneven but can never drop or repeat +# a test. +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "usage: $0 < tests" >&2 + exit 2 +fi +weights=$1 +count=$2 +index=$3 + +case "$count" in '' | *[!0-9]*) echo "shard count must be a positive integer" >&2; exit 2 ;; esac +case "$index" in '' | *[!0-9]*) echo "shard index must be a non-negative integer" >&2; exit 2 ;; esac +if [ "$count" -lt 1 ] || [ "$index" -ge "$count" ]; then + echo "shard index ${index} is outside 0..$((count - 1))" >&2 + exit 2 +fi +if [ ! -f "$weights" ]; then + echo "weights file ${weights} not found" >&2 + exit 2 +fi + +awk -v n="$count" -v want="$index" -v default_weight="${DEFAULT_WEIGHT:-0.05}" ' + function centis(seconds, c) { + c = int(seconds * 100 + 0.5) + return c < 1 ? 1 : c + } + # Number of contiguous runs a greedy fill up to capacity cap produces. + function runs(cap, k, used, groups) { + groups = 1 + used = 0 + for (k = 1; k <= total; k++) { + if (used > 0 && used + cost[k] > cap) { + groups++ + used = 0 + } + used += cost[k] + } + return groups + } + FILENAME == ARGV[1] { + if ($0 ~ /^[[:space:]]*(#|$)/) next + if (NF != 2 || $2 !~ /^[0-9]+(\.[0-9]+)?$/ || $2 + 0 <= 0) { + printf "invalid weights line %d: %s\n", FNR, $0 > "/dev/stderr" + bad = 1 + exit 1 + } + weight[$1] = $2 + 0 + next + } + NF { + total++ + name[total] = $1 + cost[total] = centis(($1 in weight) ? weight[$1] : default_weight) + sum += cost[total] + if (cost[total] > heaviest) heaviest = cost[total] + } + END { + if (bad) exit 1 + if (total < n) { + printf "%d tests cannot fill %d shards\n", total, n > "/dev/stderr" + exit 1 + } + lo = heaviest + hi = sum + while (lo < hi) { + mid = int((lo + hi) / 2) + if (runs(mid) <= n) hi = mid + else lo = mid + 1 + } + shard = 0 + used = 0 + for (k = 1; k <= total; k++) { + if (used > 0 && used + cost[k] > lo) { + shard++ + used = 0 + } + used += cost[k] + if (shard == want) print name[k] + # Once the tests left equal the shards left, give each its own shard + # so no shard is empty. + if (total - k > 0 && total - k == n - 1 - shard) { + shard++ + used = 0 + } + } + } +' "$weights" - diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index b3419e5747..cc54b9a6d7 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -323,9 +323,9 @@ jobs: # internal/api alone took ~27 of the ~31 minutes of the old single # `Backend tests (api)` job under -race, which made it the critical path of # every pull request. Its top-level tests are split across these shards - # as contiguous runs of the package's own test order, and the - # `Backend tests (api)` job below keeps the required check name by passing - # only when every shard passed. + # as contiguous runs of the package's own test order, cut by estimated run + # time, and the `Backend tests (api)` job below keeps the required check + # name by passing only when every shard passed. backend-api: name: Backend tests (api-${{ matrix.index }}) needs: changes @@ -379,9 +379,15 @@ jobs: # tests still lean on package state left by the tests just before # them (for example admin bypass or session store globals), and an # interleaved split by name broke dozens of them, while contiguous - # slices keep those neighbours together. Listing with -race builds - # the same test binary the run then reuses from cache. Benchmarks - # are left out because plain go test never runs them. + # slices keep those neighbours together. The slices are cut by + # estimated run time from .github/scripts/internal-api-test-seconds.txt + # rather than by count, because a handful of tests take most of the + # time (one contract test runs about six minutes and the integration + # server tail about twenty seconds per test). Unlisted tests get a + # small default weight, and the weights only move the cut points, so + # coverage never depends on them. Listing with -race builds the same + # test binary the run then reuses from cache. Benchmarks are left out + # because plain go test never runs them. run: | set -euo pipefail listing=$(go test -race -list . ./internal/api) @@ -391,21 +397,33 @@ jobs: exit 1 fi total=$(printf '%s\n' "$tests" | wc -l | tr -d ' ') - selected=$(printf '%s\n' "$tests" | awk -v n="$API_SHARD_COUNT" -v i="$API_SHARD_INDEX" -v total="$total" \ - 'NR > int(total * i / n) && NR <= int(total * (i + 1) / n)') + selected=$(printf '%s\n' "$tests" | bash .github/scripts/select-internal-api-shard.sh \ + .github/scripts/internal-api-test-seconds.txt "$API_SHARD_COUNT" "$API_SHARD_INDEX") if [ -z "$selected" ]; then echo "::error::internal/api shard ${API_SHARD_INDEX} of ${API_SHARD_COUNT} resolved to an empty test list" exit 1 fi + others=$(printf '%s\n' "$tests" | grep -vxF -f <(printf '%s\n' "$selected") || true) + # Name whichever side is shorter. A shard holding most of the tests + # is passed as -skip of every other shard's tests, which runs the + # same set in the same order. pattern="^($(printf '%s\n' "$selected" | paste -sd '|' -))\$" + filter=(-run "$pattern") + if [ -n "$others" ]; then + skip="^($(printf '%s\n' "$others" | paste -sd '|' -))\$" + if [ "${#skip}" -lt "${#pattern}" ]; then + pattern=$skip + filter=(-run . -skip "$pattern") + fi + fi # Linux rejects a single argument over 128 KiB. Fail with a clear # instruction instead of an opaque exec error. if [ "${#pattern}" -gt 120000 ]; then echo "::error::internal/api shard pattern is ${#pattern} bytes; add a shard index and raise API_SHARD_COUNT" exit 1 fi - echo "internal/api shard ${API_SHARD_INDEX} of ${API_SHARD_COUNT} runs $(printf '%s\n' "$selected" | wc -l | tr -d ' ') of ${total} tests" - go test -race -timeout 50m -run "$pattern" ./internal/api + echo "internal/api shard ${API_SHARD_INDEX} of ${API_SHARD_COUNT} runs $(printf '%s\n' "$selected" | wc -l | tr -d ' ') of ${total} tests (${selected%%$'\n'*} .. ${selected##*$'\n'})" + go test -race -timeout 50m "${filter[@]}" ./internal/api # Required check name. Branch protection matches `Backend tests (api)` # exactly, so this job must keep that name and must fail unless every diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 52aa9a4b17..1cf07668de 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -30,12 +30,27 @@ Build and Test runs the `internal/api` race tests as four `Backend tests (api-N)` shards instead of one job, because that one package took about 27 of the 31 minutes of the old `Backend tests (api)` job and set the critical path of every pull request. Each shard lists the package's tests with `go test --race -list` from the commit under test and runs one contiguous quarter of -that list in go test's own order, so the shards cover every test exactly once, +-race -list` from the commit under test and runs one contiguous slice of that +list in go test's own order, so the shards cover every test exactly once, including tests added later, and a shard that resolves no tests fails. The order is kept on purpose. Some `internal/api` tests depend on package state left by the tests just before them, and an interleaved split by name broke -dozens of them. The required check name +dozens of them. Slices are cut by estimated run time, not test count. +Equal-count quarters ran 1.9, 8.0, 1.5 and 10.3 minutes of tests, because one +contract mock chart test runs about six minutes on the runner and the +integration server tail about twenty seconds per test. +`.github/scripts/select-internal-api-shard.sh` weighs each test by its seconds +in `.github/scripts/internal-api-test-seconds.txt` (the slowest tests only, +with a small default for every unlisted, new or renamed test), finds the +smallest per-shard budget that fits the list into the shard count, and fills +contiguous slices up to it, cutting the tail early if needed so no shard is +empty. The weights only move cut points. Each test is assigned one shard in a +single pass, so a stale weights file can unbalance the shards but never drop +or repeat a test, and the file is refreshed from a `-race -json` run when one +shard drifts well past the others. A shard holding most of the tests passes +`-skip` of every other shard's tests instead of naming its own, which runs the +same tests in the same order and keeps the single argument under the 120,000 +byte ceiling. The required check name `Backend tests (api)` belongs to a verdict job that passes only when every shard succeeded, and `Backend tests (rest-0)` and `Backend tests (rest-1)` keep their names and package split. All shards still expand for documentation-only diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 9271b4a1ba..fc356b735b 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4511,8 +4511,10 @@ ".dockerignore", ".github/ISSUE_TEMPLATE/v6_rc_feedback.yml", ".github/scripts/check-demo-reachability.sh", + ".github/scripts/internal-api-test-seconds.txt", ".github/scripts/recover-demo-runtime.sh", ".github/scripts/resolve-demo-runtime-profile.sh", + ".github/scripts/select-internal-api-shard.sh", ".github/scripts/setup-demo-ssh.sh", ".github/workflows/backfill-release-assets.yml", ".github/workflows/build-and-test.yml", @@ -5035,6 +5037,8 @@ "label": "frontend dependency security proof", "match_prefixes": [], "match_files": [ + ".github/scripts/internal-api-test-seconds.txt", + ".github/scripts/select-internal-api-shard.sh", ".github/workflows/build-and-test.yml", "frontend-modern/package-lock.json", "frontend-modern/package.json" diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 17d3165fe9..bb72037e38 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -3840,11 +3840,17 @@ func TestBackendAPIShardsKeepRequiredCheckExhaustive(t *testing.T) { // The list comes from the commit under test, so a new test cannot be // missed, and contiguous slices of go test's own order put it in // exactly one shard while keeping order-coupled neighbours together. + // The checked-in weights only choose where those slices are cut. "go test -race -list . ./internal/api", - `'NR > int(total * i / n) && NR <= int(total * (i + 1) / n)'`, + "bash .github/scripts/select-internal-api-shard.sh \\\n .github/scripts/internal-api-test-seconds.txt \"$API_SHARD_COUNT\" \"$API_SHARD_INDEX\")", "resolved to an empty test list", "go test -list found no tests in ./internal/api", - `go test -race -timeout 50m -run "$pattern" ./internal/api`, + // A shard holding most tests is named by skipping every other + // shard's tests, which keeps its argument under the exec limit. + `others=$(printf '%s\n' "$tests" | grep -vxF -f <(printf '%s\n' "$selected") || true)`, + `filter=(-run . -skip "$pattern")`, + `if [ "${#pattern}" -gt 120000 ]; then`, + `go test -race -timeout 50m "${filter[@]}" ./internal/api`, "PULSE_DATA_DIR: /tmp/pulse-test-data", } { if !strings.Contains(shards, required) { @@ -3873,6 +3879,95 @@ func TestBackendAPIShardsKeepRequiredCheckExhaustive(t *testing.T) { if got := strings.Count(workflow, "name: Backend tests (api)\n"); got != 1 { t.Fatalf("exactly one job may carry the required Backend tests (api) name, found %d", got) } + + assertInternalAPIShardSelectionExhaustive(t, want) +} + +// assertInternalAPIShardSelectionExhaustive runs the shard selector the way +// the workflow does and proves that, whatever the weights say, the shards are +// non-empty contiguous slices that together cover the list exactly once in +// order. +func assertInternalAPIShardSelectionExhaustive(t *testing.T, workflowShards int) { + t.Helper() + selector := repoFile(".github", "scripts", "select-internal-api-shard.sh") + weightsPath := repoFile(".github", "scripts", "internal-api-test-seconds.txt") + weightsContent, err := os.ReadFile(weightsPath) + if err != nil { + t.Fatal(err) + } + + // Weighted names from the checked-in file interleaved with unknown ones, + // which stand in for tests added after the weights were measured. + var weighted []string + for _, line := range strings.Split(string(weightsContent), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 || strings.HasPrefix(fields[0], "#") { + continue + } + if len(fields) != 2 || !strings.HasPrefix(fields[0], "Test") { + t.Fatalf("internal/api weights line must be ` `, got %q", line) + } + if seconds, err := strconv.ParseFloat(fields[1], 64); err != nil || seconds <= 0 { + t.Fatalf("internal/api weight for %s must be positive seconds, got %q", fields[0], fields[1]) + } + weighted = append(weighted, fields[0]) + } + if len(weighted) == 0 { + t.Fatal("internal/api weights file lists no tests") + } + var tests []string + for i, name := range weighted { + tests = append(tests, name) + for j := 0; j < 1+i%7; j++ { + tests = append(tests, "TestUnweightedShardProbe"+strconv.Itoa(i)+"x"+strconv.Itoa(j)) + } + } + + emptyWeights := filepath.Join(t.TempDir(), "empty.txt") + if err := os.WriteFile(emptyWeights, nil, 0o644); err != nil { + t.Fatal(err) + } + skewedWeights := filepath.Join(t.TempDir(), "skewed.txt") + skewed := tests[len(tests)/3] + " 900\n" + tests[len(tests)-1] + " 450\n" + if err := os.WriteFile(skewedWeights, []byte(skewed), 0o644); err != nil { + t.Fatal(err) + } + + selectShard := func(weights string, count, index int, input []string) ([]string, error) { + cmd := exec.Command("bash", selector, weights, strconv.Itoa(count), strconv.Itoa(index)) + cmd.Stdin = strings.NewReader(strings.Join(input, "\n") + "\n") + out, err := cmd.Output() + if err != nil { + return nil, err + } + return strings.Fields(string(out)), nil + } + + for _, weights := range []string{weightsPath, emptyWeights, skewedWeights} { + for _, count := range []int{1, 2, workflowShards, workflowShards + 1, 9} { + var combined []string + for index := 0; index < count; index++ { + selected, err := selectShard(weights, count, index, tests) + if err != nil { + t.Fatalf("select shard %d of %d with %s: %v", index, count, filepath.Base(weights), err) + } + if len(selected) == 0 { + t.Fatalf("shard %d of %d with %s selected no tests", index, count, filepath.Base(weights)) + } + combined = append(combined, selected...) + } + if strings.Join(combined, "\n") != strings.Join(tests, "\n") { + t.Fatalf("%d shards with %s do not cover the test list exactly once in order", count, filepath.Base(weights)) + } + } + } + + if _, err := selectShard(weightsPath, workflowShards, workflowShards, tests); err == nil { + t.Fatal("shard selector must reject an index outside the shard count") + } + if _, err := selectShard(weightsPath, 3, 0, tests[:2]); err == nil { + t.Fatal("shard selector must fail when there are fewer tests than shards") + } } func TestFrontendDependencySecurityAuditsAreRequired(t *testing.T) { From 97f894b009f7f629bc78d415ed86cc177561ccca Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:19:54 +0100 Subject: [PATCH 2/5] Retire Relay labels and upsells inside Pulse Relay left public checkout on 2026-09-29: it only ever connected the Pulse Mobile app, which retires on 31 March 2027, and existing Relay subscribers now carry Pro entitlements. The app still sold it. Every Community install saw a "Get alerts on your phone ... Available with Relay and Pro plans" upgrade panel on Alerts destinations, the plan screen offered a Relay card with "Remote web access via Relay", and the settings section was called Remote Access although Relay never reached the web UI. The Alerts push panel now renders only on instances that have the relay feature, with no upsell. The settings section, nav, header and locale catalogs say Pulse Mobile and carry the retirement date. Community sees only the Pro comparison card and Relay-tier licenses see none; gated mobile features name Pro as their minimum plan. The relay feature is labelled "Pulse Relay (Mobile Connection)" in the catalog, plan copy no longer claims remote web access, the backend pairing diagnostics point at Settings > Pulse Mobile, and the user docs, including PRIVACY.md, state that Relay does not provide remote web UI access. --- docs/API.md | 2 +- docs/CLOUD.md | 2 +- docs/PRIVACY.md | 2 +- docs/PULSE_PRO.md | 2 +- docs/RELAY.md | 18 ++-- .../v6/internal/subsystems/agent-lifecycle.md | 6 ++ .../v6/internal/subsystems/alerts.md | 9 ++ .../v6/internal/subsystems/api-contracts.md | 8 ++ .../v6/internal/subsystems/cloud-paid.md | 12 +++ .../subsystems/frontend-primitives.md | 8 ++ .../internal/subsystems/security-privacy.md | 8 ++ .../internal/subsystems/storage-recovery.md | 6 ++ frontend-modern/browser-verification.json | 92 ++++++++-------- frontend-modern/public/docs/API.md | 2 +- frontend-modern/public/docs/CLOUD.md | 2 +- frontend-modern/public/docs/PRIVACY.md | 2 +- frontend-modern/public/docs/PULSE_PRO.md | 2 +- frontend-modern/public/docs/RELAY.md | 18 ++-- .../Settings/RelaySettingsPanel.tsx | 14 +-- .../__tests__/ProLicensePanel.test.tsx | 21 ++-- .../RelaySettingsPanel.runtime.test.tsx | 23 ++-- .../__tests__/settingsArchitecture.test.ts | 4 + ...settingsFeatureGates.branchcov0713.test.ts | 4 +- .../settingsHeaderMeta.branchcov0713.test.ts | 12 +-- .../__tests__/settingsLocalization.test.ts | 14 +-- .../settingsNavigation.integration.test.tsx | 4 +- .../__tests__/settingsReadOnlyPanels.test.tsx | 2 +- .../components/Settings/settingsHeaderMeta.ts | 2 +- .../components/Settings/settingsNavCatalog.ts | 2 +- .../Settings/useRelaySettingsPanelState.ts | 4 +- .../AlertPushDestinationsSection.test.tsx | 35 +----- .../alerts/AlertPushDestinationsSection.tsx | 36 ++----- .../useAlertDestinationsState.test.tsx | 25 +++++ .../features/alerts/tabs/DestinationsTab.tsx | 4 - frontend-modern/src/i18n/messages.de.ts | 6 +- frontend-modern/src/i18n/messages.es.ts | 6 +- frontend-modern/src/i18n/messages.ts | 6 +- .../stores/__tests__/systemSettings.test.ts | 2 + .../alertDestinationsPresentation.test.ts | 11 +- .../src/utils/__tests__/docsLinks.test.ts | 12 +++ ...icensePresentation.branchcov0723pm.test.ts | 8 +- ...icensePresentation.branchcov0725am.test.ts | 12 +-- .../licensePresentation.coverage.test.ts | 35 +++--- .../__tests__/licensePresentation.test.ts | 102 +++++++----------- .../utils/__tests__/relayPresentation.test.ts | 21 ++-- .../utils/__tests__/selfHostedPlans.test.ts | 29 +++-- .../utils/alertDestinationsPresentation.ts | 7 +- .../src/utils/licensePresentation.ts | 24 ++--- .../src/utils/relayPresentation.ts | 12 +-- .../selfHostedFeatureCatalog.generated.ts | 4 +- frontend-modern/src/utils/selfHostedPlans.ts | 16 ++- internal/api/contract_test.go | 4 +- internal/api/onboarding_handlers.go | 4 +- internal/license/coverage_additional_test.go | 2 +- pkg/licensing/features_test.go | 4 +- pkg/licensing/self_hosted_feature_catalog.go | 4 +- 56 files changed, 373 insertions(+), 365 deletions(-) diff --git a/docs/API.md b/docs/API.md index aa44a080f7..c5a7677968 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1586,7 +1586,7 @@ TrueNAS resources (pools, datasets, disks, ZFS snapshots, replication tasks, ale --- -## 📱 Relay / Mobile Remote Access (Relay and Above) +## 📱 Relay / Pulse Mobile (retiring 31 March 2027) End-to-end encrypted relay protocol for mobile connectivity. diff --git a/docs/CLOUD.md b/docs/CLOUD.md index 3037738d16..c64b1e8ece 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -62,7 +62,7 @@ Add your Proxmox VE, PBS, PMG, or TrueNAS systems via **Settings → Infrastruct ### 4. Set Up Mobile Access -Relay is enabled by default on Cloud instances. Open **Settings → Remote Access** to prepare pairing and connect once mobile beta/public access is enabled. +Relay is enabled by default on Cloud instances. Open **Settings → Pulse Mobile** to pair a phone. Pulse Mobile is being retired on 31 March 2027. ## Data & Privacy diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index e3db0c458f..cdebc02e3f 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -420,5 +420,5 @@ The telemetry implementation is in [`internal/telemetry/telemetry.go`](../intern Pulse can make outbound connections when you enable specific features: - **AI providers**: when AI features are configured, Pulse sends only the context required for your request to the provider you chose. This can include active Patrol objective briefs and their optional operator context when they apply to a Patrol run. Retained objective text and observer artifacts are encrypted at rest in the local organization data directory; objective text is not included in Pulse usage telemetry. A provider may return a model-authored observer proposal for an uncovered objective; Pulse encrypts that artifact with the retained objective, excludes it from public objective reads and later prompt seeds, and does not include its content in usage telemetry or audit messages. Saving an objective by itself does not call a model. Local providers stay on your network; non-local hosted providers receive provider-bound context directly from your Pulse instance. AI prompts from self-managed installs do not transit Pulse infrastructure. Before non-local model requests leave the instance, governed resource details use the same resource-policy redaction shown in Data Handling: local-only resource details are omitted from detailed prompt sections or replaced with policy-safe summaries, and known restricted resource identifiers are redacted where they appear in provider-bound context. See `docs/AI.md`. -- **Relay / Remote Access**: when relay is enabled, Pulse connects to the configured relay endpoint to enable secure remote web access, Pulse Mobile pairing for handoff, and push notifications. See Settings → Remote Access. +- **Relay / Pulse Mobile**: when Pulse Mobile connections are on, Pulse connects to the configured relay endpoint so paired Pulse Mobile devices can reach this instance and receive push notifications. Relay does not provide remote access to the web UI. See Settings → Pulse Mobile. Pulse Mobile is being retired on 31 March 2027. - **Update checks**: Pulse can check for new releases/updates (for example via GitHub release metadata) depending on your deployment and configuration. diff --git a/docs/PULSE_PRO.md b/docs/PULSE_PRO.md index deb8481b4c..f4b4bb2efc 100644 --- a/docs/PULSE_PRO.md +++ b/docs/PULSE_PRO.md @@ -137,7 +137,7 @@ This matrix reflects the entitlement keys enforced in code plus the runtime hist | Constant | Capability Key | Display Name | Community | Relay | Pro | Cloud | Primary Gating Mechanism / Notes | |---|---|---|:---:|:---:|:---:|:---:|---| | `FeatureAIPatrol` | `ai_patrol` | Pulse Patrol (Background Health Checks) | Y | Y | Y | Y | Patrol itself is available on Community with your own provider or local model. Higher-autonomy outcomes and fix execution are separately gated. | -| `FeatureRelay` | `relay` | Remote Access (Mobile Relay) | N | Y | Y | Y | API route gating via `RequireLicenseFeature(..., relay, ...)` for relay settings and onboarding endpoints. | +| `FeatureRelay` | `relay` | Pulse Relay (Mobile Connection) | N | Y | Y | Y | API route gating via `RequireLicenseFeature(..., relay, ...)` for relay settings and onboarding endpoints. | | `FeatureAIAlerts` | `ai_alerts` | Patrol Investigates Issues and Explains the Root Cause | N | N | Y | Y | API route gating via `RequireLicenseFeature(..., ai_alerts, ...)`. | | `FeatureAIAutoFix` | `ai_autofix` | Patrol Applies Safe Fixes and Verifies the Result | N | N | Y | Y | Required for governed fix execution and automatic Patrol actions. | | `FeatureKubernetesAI` | `kubernetes_ai` | Kubernetes AI Analysis (Compatibility) | N | N | Y | Y | Legacy compatibility gate for `/api/ai/kubernetes/analyze`; not a primary marketed v6 Pro plan pillar. | diff --git a/docs/RELAY.md b/docs/RELAY.md index bda6176f2b..cb791d27f5 100644 --- a/docs/RELAY.md +++ b/docs/RELAY.md @@ -1,8 +1,10 @@ -# Relay / Pulse Mobile Handoff (Relay and Above) +# Relay / Pulse Mobile -Pulse Relay provides **end-to-end encrypted remote access** foundations for Pulse instances. It allows secure remote connectivity without exposing your Pulse server to the public internet. +> **Pulse Mobile is being retired on 31 March 2027.** Paired phones keep working until then. Relay is no longer sold as a plan; existing Relay subscribers have Pro features at their current price. For alerts on your phone afterwards, add an ntfy, Gotify, or Pushover destination under Alerts, and reach the Pulse web UI away from home through your own VPN or tunnel. -> Supported Pulse Mobile clients pair from **Settings → Remote Access** using a QR code or deep link and connect through Pulse Relay over end-to-end encrypted remote access. +Pulse Relay is the end-to-end encrypted connection between a Pulse instance and paired Pulse Mobile devices. It lets the app reach your server without exposing it to the public internet. It does not provide remote access to the Pulse web UI. + +> Supported Pulse Mobile clients pair from **Settings → Pulse Mobile** using a QR code or deep link and connect through Pulse Relay over an end-to-end encrypted channel. ## How It Works @@ -20,7 +22,7 @@ Pulse Relay provides **end-to-end encrypted remote access** foundations for Puls ## Quick Start -1. Go to **Settings → Remote Access**. +1. Go to **Settings → Pulse Mobile**. 2. Toggle relay **On**. 3. Use the **QR Code** or **Deep Link** to pair a supported Pulse Mobile client. 4. Your paired mobile client connects through relay. @@ -49,7 +51,7 @@ Relay was designed with a zero-trust model: ### UI -**Settings → Remote Access** — toggle on/off, view QR code, and manage relay pairing sessions. +**Settings → Pulse Mobile** — toggle on/off, view QR code, and manage relay pairing sessions. ### Environment Variables @@ -84,7 +86,7 @@ Relay configuration is stored encrypted in `relay.enc` in the Pulse data directo 1. Pulse Mobile is in early access. Relay and Pro customers get install links from the authenticated [download page](https://pulserelay.pro/download.html). 2. Open Pulse Mobile and tap **Connect to Server**. -3. Scan the QR code from **Settings → Remote Access** in your Pulse web UI. +3. Scan the QR code from **Settings → Pulse Mobile** in your Pulse web UI. 4. The app connects via the relay for push notifications and secure Open Pulse handoff. ### Multiple Servers @@ -109,14 +111,14 @@ Pulse Mobile can pair with multiple Pulse instances. Each pairing has its own en ### Pulse Mobile can't connect -1. Verify relay is enabled in **Settings → Remote Access**. +1. Verify relay is enabled in **Settings → Pulse Mobile**. 2. Confirm your mobile account has beta access. 3. Re-scan the QR code — sessions can expire. 4. Ensure your mobile device has internet access. ### Open Pulse handoff not loading -1. Check the relay connection status in **Settings → Remote Access**. +1. Check the relay connection status in **Settings → Pulse Mobile**. 2. Look for WebSocket reconnection messages in Pulse logs. 3. Restart Pulse Mobile. diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 4a8ce936ad..2a5a1dd769 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -8321,3 +8321,9 @@ Doctor's light-tone guidance uses opaque text colors because the current are translucent. The 49-agent synthetic browser pass follows the deep link, inspects the guidance and status rows at phone and desktop widths, and keeps update commands and credential authority unchanged. + +### Pulse Mobile pairing copy only + +`internal/api/onboarding_handlers.go` changed only the human-readable pairing +readiness messages so they name Settings > Pulse Mobile. No agent +registration, lifecycle or install path changed. diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 9df72c3422..44d040e556 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -3012,3 +3012,12 @@ or sending provider notifications. Component proof is not installed delivery. Formatting-only follow-up retains this warning-level contract. The production-component browser matrix was rerun after formatting at desktop and narrow widths, including all/critical/warning save/reload, cancel, and warning webhook creation. + +### Pulse Mobile push destination without an upsell + +The Alerts destinations page renders the mobile push panel only when the +instance has the `relay` feature. Community installs see no panel and no +upgrade prompt, and never load or save relay push settings. Licensed instances +keep the minimum-severity control and the link to Pulse Mobile settings, with +the 31 March 2027 retirement date in the copy. Webhook, email, Apprise and +dead-man destinations are unaffected. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index ee1a5418b5..2a9ec4e1cd 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -10981,3 +10981,11 @@ text-input normalization, literal quoted token bytes, root/sudo private-file permissions, cleanup and early exit when download or preflight fails. This client transport repair does not establish the reporter's native pfSense abort cause or successful offline installation. + +### Pulse Mobile pairing readiness wording + +The onboarding readiness diagnostics for a disabled or unconnected relay name +the renamed Settings > Pulse Mobile section instead of Remote Access. Codes, +severities and the response shape are unchanged. The API reference heading +for the relay protocol records that Pulse Mobile retires on 31 March 2027, and +the published copy stays identical to `docs/API.md`. diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 4a22aee749..34c5094087 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -3640,3 +3640,15 @@ bootstrap or upgrade state, and it adds no commercial prompt. Paid Patrol modes stay governed on the Patrol and Actions surfaces, which remain reachable by route. `App.architecture.test.ts` pins that the gate reads the same capability as the Assistant launcher. + +### Relay retired inside the app + +Relay left public checkout on 2026-09-29, and existing Relay subscribers carry +Pro entitlements through the pulse-pro license server. In-app commercial +presentation therefore never offers Relay: Community sees only the Pro +comparison card, a Relay-tier license sees none, `FEATURE_MIN_TIER_LABELS` +maps `relay`, `mobile_app` and `push_notifications` to Pro, and the Pulse +Mobile settings gate and Alerts push panel carry no upgrade prompt. The +`relay` feature is labelled "Pulse Relay (Mobile Connection)" because it never +provided remote access to the web UI. Pulse Mobile retires on 31 March 2027; +until then licensed instances keep pairing and push unchanged. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index d43728358d..517793eef9 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7471,3 +7471,11 @@ instead of saying Patrol runs in Watch only mode or pitching Pro Patrol modes for a feature that is not running (issue #905). With AI on, the Watch only, switch and upgrade branches are unchanged. `actionsWatchOnlyEmptyState.test.ts` pins both states and the page wiring. + +### Pulse Mobile settings section label + +The `system-relay` settings section is labelled Pulse Mobile in the nav +catalog, the header metadata and every locale catalog; the product name is not +translated. Its route id, feature gate and read capability are unchanged. +Relay never provided remote access to the web UI, so no settings chrome may +label it Remote Access. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 73a5bf5e52..66024394cb 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -2914,3 +2914,11 @@ The System settings item formerly labelled "Pulse server updates" is now that still routes agent updates to Infrastructure. This is locale copy only: the `system-updates` route id, its `systemSettingsRead` capability gate and every update endpoint and authorization check are unchanged. + +### Relay privacy description corrected + +`PRIVACY.md` states that the relay connects paired Pulse Mobile devices and +delivers push notifications, and that it does not provide remote access to the +web UI. The earlier "secure remote web access" wording described a capability +that never existed. No data flow changed, and the published docs mirror stays +identical. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 0e49660db6..293ae1f919 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -6174,3 +6174,9 @@ shortcuts and the shortcuts help so Patrol entry points hide while AI is off. The change is navigation presentation only. It adds no storage, backup, snapshot or recovery surface, and it changes no route, authority or recovery state. The Recovery settings item and its capability gate are untouched. + +### Pulse Mobile pairing copy only + +`internal/api/onboarding_handlers.go` changed only the human-readable pairing +readiness messages so they name Settings > Pulse Mobile. This opens no new +storage or recovery path. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index c94f0106ec..01e886f36f 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,73 +1,67 @@ { "version": 1, - "base_sha": "2606c9d36a0b91c40fbc234fecad2167b7979fec", - "verified_at": "2026-09-28T22:20:33Z", + "base_sha": "fcbb7e58ad514ac146f666f49075039d40d829e2", + "verified_at": "2026-09-29T12:15:52Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/App.tsx", - "frontend-modern/src/AppLayout.tsx", + "frontend-modern/src/components/Settings/RelaySettingsPanel.tsx", "frontend-modern/src/components/Settings/settingsHeaderMeta.ts", "frontend-modern/src/components/Settings/settingsNavCatalog.ts", - "frontend-modern/src/components/shared/KeyboardShortcutsModal.tsx", - "frontend-modern/src/components/shared/commandPaletteModel.ts", - "frontend-modern/src/components/shared/useCommandPaletteState.ts", - "frontend-modern/src/features/actions/actionPresentation.ts", - "frontend-modern/src/hooks/useKeyboardShortcuts.ts", + "frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts", + "frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx", + "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx", "frontend-modern/src/i18n/messages.de.ts", "frontend-modern/src/i18n/messages.es.ts", "frontend-modern/src/i18n/messages.ts", - "frontend-modern/src/pages/Actions.tsx", - "frontend-modern/src/types/config.ts", - "frontend-modern/src/utils/updatesPresentation.ts" + "frontend-modern/src/utils/alertDestinationsPresentation.ts", + "frontend-modern/src/utils/licensePresentation.ts", + "frontend-modern/src/utils/relayPresentation.ts", + "frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts", + "frontend-modern/src/utils/selfHostedPlans.ts" ], "content_sha256": { - "frontend-modern/src/App.tsx": "a6ca67087cce917dc28914701262e6f631eb03621c24c8a773d5d025beb41215", - "frontend-modern/src/AppLayout.tsx": "d1d8bd884c43a20c1dbbde2ddb9f3278eb885b02c21e4cc3fa3fa1089cd1d320", - "frontend-modern/src/components/Settings/settingsHeaderMeta.ts": "d024df468000f183b2452d6b3fcb60fdce1b050107cd2e9d4ac586c4f13f2ddd", - "frontend-modern/src/components/Settings/settingsNavCatalog.ts": "8d3bff1c977b9db02c807d03fd80d40dabfc18c6a59809358c52a938a9c97c88", - "frontend-modern/src/components/shared/KeyboardShortcutsModal.tsx": "a27ccd1bdccf31f1890779fc661ad4e896786594b99ff419160ce6c556fc7c96", - "frontend-modern/src/components/shared/commandPaletteModel.ts": "69a75c17579239c407ba18820e9f4d47d8b8b0e587cdcc3bb154a684b2a17047", - "frontend-modern/src/components/shared/useCommandPaletteState.ts": "95ff00c6e790f17fdd849e845b34e9047f655ec89ca279032ab764da283b4cd4", - "frontend-modern/src/features/actions/actionPresentation.ts": "8e67a99e744cf7dbbc1fc53da1d9697af78a90d95779df3553ee8867c3c51496", - "frontend-modern/src/hooks/useKeyboardShortcuts.ts": "f55d67c136d5d8e21ed220b33eac0512d5fae5b5aa835f60fa2570159c140d30", - "frontend-modern/src/i18n/messages.de.ts": "e55a115865e8b2c9cf4352eecac1a2f8996f4e0f51b9c14ddb7cfbe20e5b2f40", - "frontend-modern/src/i18n/messages.es.ts": "a348d63dd816e848cd75a0b5114b0f77c719d16117d9c86db719036fbe106af0", - "frontend-modern/src/i18n/messages.ts": "728aa1cf4b42698e14f16593825249c6a190c43d4ef28e2d4e28756c7c1990db", - "frontend-modern/src/pages/Actions.tsx": "88ab28e9333652e81770e570c140aa19f0b6d833be3b65fd41afd2e9614f60c5", - "frontend-modern/src/types/config.ts": "49ad539bcefc2f87380b744c1da387401151e3f4af9047db8a85b1ce0b70322e", - "frontend-modern/src/utils/updatesPresentation.ts": "4696f8426385aaf287ab610746ca278acc7e23bd377c9739fcfedcf635910634" + "frontend-modern/src/components/Settings/RelaySettingsPanel.tsx": "1def33d23464ed3e0a6127e75f16e7da814c9ca7d8fc6a680532fbef05e67755", + "frontend-modern/src/components/Settings/settingsHeaderMeta.ts": "4847df256a489833c5836e0babcd651ea85d640a204288979f965bda4ff7432b", + "frontend-modern/src/components/Settings/settingsNavCatalog.ts": "d4832713b84d88033fa15211342b6ff8a243e20c56732c5431aed8e10b22e9c2", + "frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts": "56525ac9bd0f3634adea1cccbc53c557caed8faad223f48d788c074ab2fa8a86", + "frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx": "3a160748f1bd0f42ba412757ca919803a440ad8afb0a946d0d5807188c4bc5a0", + "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "1e07af69a4f4268601f8227500e79eb6a827c49f7b0d62b283129ff5fa2935f9", + "frontend-modern/src/i18n/messages.de.ts": "2daa6b5a6bb2a59bf7846fa2f93310a48065fe7bc3082777cbb51c2fd5f107e2", + "frontend-modern/src/i18n/messages.es.ts": "7052ac4596ef1d36b46935f1bf451f552aee87abe45e23840b3e9d570b215205", + "frontend-modern/src/i18n/messages.ts": "42c6798b32c5647c89a9f658b40e605773d85e5478a4b889bc0469ef3b2efe8e", + "frontend-modern/src/utils/alertDestinationsPresentation.ts": "82980cfc35fab1e91c5484b75e1bc3b84f6c2234e26ed5306b4583e89da3f880", + "frontend-modern/src/utils/licensePresentation.ts": "3573f465207e64e2984c5f18dc89f644b6a88de6e124779840714b01f572e3d6", + "frontend-modern/src/utils/relayPresentation.ts": "9a0d1000d91e307d31b7176be7d4529a150f7364fd913a4b379e2c96a3aa9e1c", + "frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts": "64baf62ae41b9832381ac2a91f7c9724131e765f2493cb894efabeed2ae16d0e", + "frontend-modern/src/utils/selfHostedPlans.ts": "5efab41402fa3409f307398d5feb3b8ccdbf16ac01d6ce0cdacc3024c640b970" }, "routes": [ - "/actions", - "/patrol", - "/settings/system-updates", - "/preview/setup-complete?scenario=empty", - "/preview/setup-complete?scenario=vmware-api-backed" + "/settings/system-relay", + "/alerts/notifications", + "/settings/pulse-intelligence/billing/plan", + "/settings/system-general" ], "viewports": [ { - "width": 1440, - "height": 900 + "width": 1024, + "height": 768 }, { - "width": 375, - "height": 812 + "width": 390, + "height": 844 } ], "states": [ - "AI off and AI on with mocked authenticated API responses: Actions empty Open tab, app-shell Patrol navigation, command palette and shortcuts at desktop and phone widths", - "AI off: plain Actions empty-state copy; no Patrol navigation, palette command, shortcut help row or g r navigation", - "AI on: Watch only guidance and Patrol entry points; g r reaches /patrol", - "Empty setup completion preview at desktop and VMware API-backed setup completion preview at phone width show first-source guidance and credential disclosure", - "System Updates settings route at desktop and phone widths labels the item Updates and retains the agent-update handoff to Infrastructure" + "Licensed instance (MSP test license from the offline issuer, relay capability present): Settings > Pulse Mobile shows 'Pair Pulse Mobile' with the 31 March 2027 retirement copy, the 'Allow Pulse Mobile connections' toggle, no Remote Access wording and no upgrade prompt", + "Licensed instance: Alerts > Notifications shows the Mobile push notifications panel with retirement copy, minimum-severity control and the 'Open Pulse Mobile settings' link", + "Licensed instance: Plans & Billing lists the relay feature as 'Pulse Relay (Mobile Connection)' with no Remote Access or remote web access wording", + "Community instance (no license): Alerts > Notifications has no Mobile push panel and no Relay upsell while webhook, email and Apprise destinations remain", + "Community instance: settings navigation has no Pulse Mobile or Remote Access item; the direct /settings/system-relay route shows the retirement message with no upgrade link", + "Community instance: Plans & Billing shows the Community plan with no Relay plan card or remote web access copy" ], "interactions": [ - "Loaded /actions, opened and closed command palette and keyboard shortcuts, then pressed g r in both AI states at both widths", - "Loaded both setup completion previews and checked connection choices, next-step copy and horizontal overflow", - "Loaded /settings/system-updates at both widths and checked heading and copy" - ], - "command": "pulse-worker-browser web-composed-proof.cjs from assigned workspace root; offline Vite preview and Chromium 141.0.7390.37", - "artifacts": "/var/lib/pulse-maintainer/worker-outputs/web-product-mc08uh4u/browser-composed-2606c9d36a", - "notes": "The browser used mocked API responses and no live backend, paired agent or installed release. Mocked WebSocket reconnect warnings do not establish runtime connectivity. Screenshots of Actions, command palette, shortcut help, setup and Updates at both widths were inspected.", - "verified_source_sha": "2606c9d36a0b91c40fbc234fecad2167b7979fec" + "Activated the offline issuer test license through /api/license/activate on the licensed stack and confirmed relay in /api/license/entitlements", + "Loaded /settings/system-relay and /alerts/notifications on the licensed stack at 1024px and 390px, checked the toggle and settings link stay inside the viewport with no horizontal overflow", + "Loaded /alerts/notifications, /settings/system-general, /settings/system-relay and /settings/system/billing/plan on the Community stack and checked nav items, panels and links" + ] } diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index aa44a080f7..c5a7677968 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -1586,7 +1586,7 @@ TrueNAS resources (pools, datasets, disks, ZFS snapshots, replication tasks, ale --- -## 📱 Relay / Mobile Remote Access (Relay and Above) +## 📱 Relay / Pulse Mobile (retiring 31 March 2027) End-to-end encrypted relay protocol for mobile connectivity. diff --git a/frontend-modern/public/docs/CLOUD.md b/frontend-modern/public/docs/CLOUD.md index 3037738d16..c64b1e8ece 100644 --- a/frontend-modern/public/docs/CLOUD.md +++ b/frontend-modern/public/docs/CLOUD.md @@ -62,7 +62,7 @@ Add your Proxmox VE, PBS, PMG, or TrueNAS systems via **Settings → Infrastruct ### 4. Set Up Mobile Access -Relay is enabled by default on Cloud instances. Open **Settings → Remote Access** to prepare pairing and connect once mobile beta/public access is enabled. +Relay is enabled by default on Cloud instances. Open **Settings → Pulse Mobile** to pair a phone. Pulse Mobile is being retired on 31 March 2027. ## Data & Privacy diff --git a/frontend-modern/public/docs/PRIVACY.md b/frontend-modern/public/docs/PRIVACY.md index e3db0c458f..cdebc02e3f 100644 --- a/frontend-modern/public/docs/PRIVACY.md +++ b/frontend-modern/public/docs/PRIVACY.md @@ -420,5 +420,5 @@ The telemetry implementation is in [`internal/telemetry/telemetry.go`](../intern Pulse can make outbound connections when you enable specific features: - **AI providers**: when AI features are configured, Pulse sends only the context required for your request to the provider you chose. This can include active Patrol objective briefs and their optional operator context when they apply to a Patrol run. Retained objective text and observer artifacts are encrypted at rest in the local organization data directory; objective text is not included in Pulse usage telemetry. A provider may return a model-authored observer proposal for an uncovered objective; Pulse encrypts that artifact with the retained objective, excludes it from public objective reads and later prompt seeds, and does not include its content in usage telemetry or audit messages. Saving an objective by itself does not call a model. Local providers stay on your network; non-local hosted providers receive provider-bound context directly from your Pulse instance. AI prompts from self-managed installs do not transit Pulse infrastructure. Before non-local model requests leave the instance, governed resource details use the same resource-policy redaction shown in Data Handling: local-only resource details are omitted from detailed prompt sections or replaced with policy-safe summaries, and known restricted resource identifiers are redacted where they appear in provider-bound context. See `docs/AI.md`. -- **Relay / Remote Access**: when relay is enabled, Pulse connects to the configured relay endpoint to enable secure remote web access, Pulse Mobile pairing for handoff, and push notifications. See Settings → Remote Access. +- **Relay / Pulse Mobile**: when Pulse Mobile connections are on, Pulse connects to the configured relay endpoint so paired Pulse Mobile devices can reach this instance and receive push notifications. Relay does not provide remote access to the web UI. See Settings → Pulse Mobile. Pulse Mobile is being retired on 31 March 2027. - **Update checks**: Pulse can check for new releases/updates (for example via GitHub release metadata) depending on your deployment and configuration. diff --git a/frontend-modern/public/docs/PULSE_PRO.md b/frontend-modern/public/docs/PULSE_PRO.md index deb8481b4c..f4b4bb2efc 100644 --- a/frontend-modern/public/docs/PULSE_PRO.md +++ b/frontend-modern/public/docs/PULSE_PRO.md @@ -137,7 +137,7 @@ This matrix reflects the entitlement keys enforced in code plus the runtime hist | Constant | Capability Key | Display Name | Community | Relay | Pro | Cloud | Primary Gating Mechanism / Notes | |---|---|---|:---:|:---:|:---:|:---:|---| | `FeatureAIPatrol` | `ai_patrol` | Pulse Patrol (Background Health Checks) | Y | Y | Y | Y | Patrol itself is available on Community with your own provider or local model. Higher-autonomy outcomes and fix execution are separately gated. | -| `FeatureRelay` | `relay` | Remote Access (Mobile Relay) | N | Y | Y | Y | API route gating via `RequireLicenseFeature(..., relay, ...)` for relay settings and onboarding endpoints. | +| `FeatureRelay` | `relay` | Pulse Relay (Mobile Connection) | N | Y | Y | Y | API route gating via `RequireLicenseFeature(..., relay, ...)` for relay settings and onboarding endpoints. | | `FeatureAIAlerts` | `ai_alerts` | Patrol Investigates Issues and Explains the Root Cause | N | N | Y | Y | API route gating via `RequireLicenseFeature(..., ai_alerts, ...)`. | | `FeatureAIAutoFix` | `ai_autofix` | Patrol Applies Safe Fixes and Verifies the Result | N | N | Y | Y | Required for governed fix execution and automatic Patrol actions. | | `FeatureKubernetesAI` | `kubernetes_ai` | Kubernetes AI Analysis (Compatibility) | N | N | Y | Y | Legacy compatibility gate for `/api/ai/kubernetes/analyze`; not a primary marketed v6 Pro plan pillar. | diff --git a/frontend-modern/public/docs/RELAY.md b/frontend-modern/public/docs/RELAY.md index bda6176f2b..cb791d27f5 100644 --- a/frontend-modern/public/docs/RELAY.md +++ b/frontend-modern/public/docs/RELAY.md @@ -1,8 +1,10 @@ -# Relay / Pulse Mobile Handoff (Relay and Above) +# Relay / Pulse Mobile -Pulse Relay provides **end-to-end encrypted remote access** foundations for Pulse instances. It allows secure remote connectivity without exposing your Pulse server to the public internet. +> **Pulse Mobile is being retired on 31 March 2027.** Paired phones keep working until then. Relay is no longer sold as a plan; existing Relay subscribers have Pro features at their current price. For alerts on your phone afterwards, add an ntfy, Gotify, or Pushover destination under Alerts, and reach the Pulse web UI away from home through your own VPN or tunnel. -> Supported Pulse Mobile clients pair from **Settings → Remote Access** using a QR code or deep link and connect through Pulse Relay over end-to-end encrypted remote access. +Pulse Relay is the end-to-end encrypted connection between a Pulse instance and paired Pulse Mobile devices. It lets the app reach your server without exposing it to the public internet. It does not provide remote access to the Pulse web UI. + +> Supported Pulse Mobile clients pair from **Settings → Pulse Mobile** using a QR code or deep link and connect through Pulse Relay over an end-to-end encrypted channel. ## How It Works @@ -20,7 +22,7 @@ Pulse Relay provides **end-to-end encrypted remote access** foundations for Puls ## Quick Start -1. Go to **Settings → Remote Access**. +1. Go to **Settings → Pulse Mobile**. 2. Toggle relay **On**. 3. Use the **QR Code** or **Deep Link** to pair a supported Pulse Mobile client. 4. Your paired mobile client connects through relay. @@ -49,7 +51,7 @@ Relay was designed with a zero-trust model: ### UI -**Settings → Remote Access** — toggle on/off, view QR code, and manage relay pairing sessions. +**Settings → Pulse Mobile** — toggle on/off, view QR code, and manage relay pairing sessions. ### Environment Variables @@ -84,7 +86,7 @@ Relay configuration is stored encrypted in `relay.enc` in the Pulse data directo 1. Pulse Mobile is in early access. Relay and Pro customers get install links from the authenticated [download page](https://pulserelay.pro/download.html). 2. Open Pulse Mobile and tap **Connect to Server**. -3. Scan the QR code from **Settings → Remote Access** in your Pulse web UI. +3. Scan the QR code from **Settings → Pulse Mobile** in your Pulse web UI. 4. The app connects via the relay for push notifications and secure Open Pulse handoff. ### Multiple Servers @@ -109,14 +111,14 @@ Pulse Mobile can pair with multiple Pulse instances. Each pairing has its own en ### Pulse Mobile can't connect -1. Verify relay is enabled in **Settings → Remote Access**. +1. Verify relay is enabled in **Settings → Pulse Mobile**. 2. Confirm your mobile account has beta access. 3. Re-scan the QR code — sessions can expire. 4. Ensure your mobile device has internet access. ### Open Pulse handoff not loading -1. Check the relay connection status in **Settings → Remote Access**. +1. Check the relay connection status in **Settings → Pulse Mobile**. 2. Look for WebSocket reconnection messages in Pulse logs. 3. Restart Pulse Mobile. diff --git a/frontend-modern/src/components/Settings/RelaySettingsPanel.tsx b/frontend-modern/src/components/Settings/RelaySettingsPanel.tsx index 19bc511ecc..390d8f97fc 100644 --- a/frontend-modern/src/components/Settings/RelaySettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/RelaySettingsPanel.tsx @@ -30,15 +30,15 @@ export const RelaySettingsPanel: Component = (props) => // Relay feature gate if (!state.relayEnabled()) { return ( - + Loading...}> } - title="Remote Access (Relay)" + title="Pulse Mobile" body={RELAY_LICENSE_REQUIRED_MESSAGE} upgradeDestination={state.upgradeDestination()} - showUpgradePrompts={state.showUpgradePrompts()} + showUpgradePrompts={false} /> @@ -47,14 +47,14 @@ export const RelaySettingsPanel: Component = (props) => } return ( - + {getSettingsConfigurationLoadingState().text}} > - Remote access settings are read-only for this account. + Pulse Mobile settings are read-only for this account. @@ -95,11 +95,11 @@ export const RelaySettingsPanel: Component = (props) =>
- Enable Remote Access + Allow Pulse Mobile connections

{RELAY_ENABLE_HELP_TEXT}

void state.handleToggleEnabled(e.currentTarget.checked)} disabled={!state.canManage() || state.saving()} diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index 49b985ca51..0897108765 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -305,11 +305,10 @@ describe('ProLicensePanel', () => { expect(screen.getByText('Select a plan')).toBeInTheDocument(); expect(screen.getByText('Available plans')).toBeInTheDocument(); - expect(screen.getByText('Relay plan')).toBeInTheDocument(); + // Relay was retired on 2026-09-29: Community sees only the Pro card. + expect(screen.queryByText('Relay plan')).not.toBeInTheDocument(); expect(screen.getByText('Pulse Pro plan')).toBeInTheDocument(); - expect(screen.getByText('Remote web access via Relay')).toBeInTheDocument(); - expect(screen.getByText('Pulse Mobile pairing')).toBeInTheDocument(); - expect(screen.getByText('Push notifications')).toBeInTheDocument(); + expect(screen.queryByText(/remote web access/i)).not.toBeInTheDocument(); expect( screen.getByText('Patrol modes: Ask first, Safe auto-fix, or Autopilot'), ).toBeInTheDocument(); @@ -830,7 +829,7 @@ describe('ProLicensePanel', () => { expect(screen.getByText('Current plan: Pulse Pro')).toBeInTheDocument(); expect( screen.getByText( - 'Pulse Pro is active on this instance. It includes Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.', + 'Pulse Pro is active on this instance. It includes Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, agent profiles, and Pulse Mobile pairing until 31 March 2027.', ), ).toBeInTheDocument(); expect(screen.getByText('Primary capabilities')).toBeInTheDocument(); @@ -853,10 +852,10 @@ describe('ProLicensePanel', () => { 'Open this only when a Pro capability looks unavailable. Normal setup is choosing Patrol mode.', ), ).toBeInTheDocument(); - expect(screen.getByText('Remote access, pairing, and push')).toBeInTheDocument(); + expect(screen.getByText('Pulse Mobile pairing and push')).toBeInTheDocument(); expect( screen.getByText( - 'Relay, Pulse Mobile pairing, and push notifications are available on this instance.', + 'Pulse Mobile pairing and push notifications are available on this instance until Pulse Mobile is retired on 31 March 2027.', ), ).toBeInTheDocument(); expect(screen.getByText('Patrol investigation and remediation')).toBeInTheDocument(); @@ -900,7 +899,7 @@ describe('ProLicensePanel', () => { expect( screen.getByText( - 'Relay is active on this instance. It includes remote web access, Pulse Mobile pairing, push notifications, and 14-day metric history.', + 'Relay is active on this instance. It includes Pulse Mobile pairing and push notifications until 31 March 2027, and 14-day metric history.', ), ).toBeInTheDocument(); expect(screen.queryByText('Available plans')).not.toBeInTheDocument(); @@ -908,9 +907,9 @@ describe('ProLicensePanel', () => { expect(screen.queryByText('Pulse Pro plan')).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument(); expect(screen.getByText('Relay status')).toBeInTheDocument(); - expect(screen.getByText('Remote access, pairing, and push')).toBeInTheDocument(); + expect(screen.getAllByText('Pulse Mobile pairing and push').length).toBeGreaterThan(0); expect(screen.getAllByText('14-day metric history').length).toBeGreaterThan(0); - expect(screen.getByText('Pulse Relay (Remote Access)')).toBeInTheDocument(); + expect(screen.getByText('Pulse Relay (Mobile Connection)')).toBeInTheDocument(); expect(screen.getByText('Pulse Mobile Pairing')).toBeInTheDocument(); expect(screen.getByText('Push Notifications')).toBeInTheDocument(); }); @@ -1098,7 +1097,7 @@ describe('ProLicensePanel', () => { expect(screen.getByText('Audit Logging')).toBeInTheDocument(); expect(screen.getByText('PDF/CSV Reporting')).toBeInTheDocument(); expect(screen.getByText('Centralized Agent Profiles')).toBeInTheDocument(); - expect(screen.getByText('Pulse Relay (Remote Access)')).toBeInTheDocument(); + expect(screen.getByText('Pulse Relay (Mobile Connection)')).toBeInTheDocument(); expect(screen.getByText('Pulse Mobile Pairing')).toBeInTheDocument(); expect(screen.getByText('Push Notifications')).toBeInTheDocument(); expect(screen.getByText('Extended Metric History')).toBeInTheDocument(); diff --git a/frontend-modern/src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx b/frontend-modern/src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx index 060931d815..42a00c8e01 100644 --- a/frontend-modern/src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx @@ -97,6 +97,7 @@ vi.mock('qrcode', () => ({ })); import { RelaySettingsPanel } from '../RelaySettingsPanel'; +import { RELAY_LICENSE_REQUIRED_MESSAGE } from '@/utils/relayPresentation'; describe('RelaySettingsPanel runtime', () => { beforeEach(() => { @@ -178,24 +179,18 @@ describe('RelaySettingsPanel runtime', () => { expect(relaySettingsPanelStateSource).not.toContain('commercialPosture()?.trial_eligible'); }); - it('shows supported Pulse Mobile pairing copy on the relay paywall', async () => { + it('explains the Pulse Mobile retirement without an upsell when the plan lacks it', async () => { hasFeatureMock.mockReturnValue(false); render(() => ); await waitFor(() => { - expect(screen.getByText('Remote Access (Relay)')).toBeInTheDocument(); + expect(screen.getByText(RELAY_LICENSE_REQUIRED_MESSAGE)).toBeInTheDocument(); }); - expect( - screen.getByText( - 'See your infrastructure and get alert push notifications anywhere with the Pulse Mobile app — no port forwarding or VPN required. Available with Relay and Pro plans.', - ), - ).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'View plans' })).toHaveAttribute( - 'href', - 'https://example.com/upgrade', - ); + expect(RELAY_LICENSE_REQUIRED_MESSAGE).toContain('retired on 31 March 2027'); + expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument(); + expect(screen.queryByText(/Remote Access/)).not.toBeInTheDocument(); }); it('loads connected relay state and generates a pairing QR payload', async () => { @@ -205,10 +200,10 @@ describe('RelaySettingsPanel runtime', () => { expect(screen.getByDisplayValue('wss://relay.example.test/ws/instance')).toBeInTheDocument(); }); - expect(screen.getByText('Pair Pulse Mobile through Relay')).toBeInTheDocument(); + expect(screen.getByText('Pair Pulse Mobile')).toBeInTheDocument(); expect( screen.getByText( - 'Install the Pulse Mobile app on your phone, then scan a QR code or open a deep link here to securely link it to this Pulse instance. Paired devices receive alert push notifications over an end-to-end encrypted connection.', + 'Install the Pulse Mobile app on your phone, then scan a QR code or open a deep link here to securely link it to this Pulse instance. Paired devices receive alert push notifications over an end-to-end encrypted connection until Pulse Mobile is retired on 31 March 2027.', ), ).toBeInTheDocument(); expect(screen.getByText('Connected')).toBeInTheDocument(); @@ -275,7 +270,7 @@ describe('RelaySettingsPanel runtime', () => { expect( screen.getByText( - 'Remote Access is enabled, but this instance does not have an active Relay token. Activate a Relay-capable plan or turn Remote Access off before pairing mobile clients.', + 'Pulse Mobile connections are on, but this instance does not have an active Relay token. Refresh your plan, or turn Pulse Mobile connections off before pairing phones.', ), ).toBeInTheDocument(); expect(screen.queryByText('register: no license token available')).not.toBeInTheDocument(); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 969074349b..6619ee7a5b 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -375,6 +375,10 @@ describe('settings architecture guardrails', () => { ); expect(systemRelayNavBlock?.[0]).toContain("features: ['relay']"); expect(systemRelayNavBlock?.[0]).toContain('hideWhenUnavailable: true'); + // Relay only ever connected Pulse Mobile; the section is named for the app, + // never as remote access to the web UI. + expect(systemRelayNavBlock?.[0]).toContain("label: 'Pulse Mobile'"); + expect(settingsNavCatalogSource).not.toContain("label: 'Remote Access'"); expect(settingsHeaderMetaSource).toContain( 'title: SELF_HOSTED_PRO_BILLING_PRESENTATION.shellTitle', ); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsFeatureGates.branchcov0713.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsFeatureGates.branchcov0713.test.ts index f424d9f910..95b80a84ec 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsFeatureGates.branchcov0713.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsFeatureGates.branchcov0713.test.ts @@ -53,11 +53,11 @@ const trackingHas = // Hand-written snapshot of the exact reason string each gated tab must emit // when its required feature is missing. Derived from reading -// `getFeatureMinTierLabel` (relay -> 'Relay', multi_tenant -> 'MSP', +// `getFeatureMinTierLabel` (relay -> 'Pro', multi_tenant -> 'MSP', // audit_logging -> 'Pro' fallback); NOT computed by calling the helper, so a // change to either the tier map or the message template fails this loudly. const EXPECTED_LOCK_REASON: Partial> = { - 'system-relay': 'This settings section requires Relay.', + 'system-relay': 'This settings section requires Pro.', 'security-webhooks': 'This settings section requires Pro.', 'organization-overview': 'This settings section requires MSP.', 'organization-access': 'This settings section requires MSP.', diff --git a/frontend-modern/src/components/Settings/__tests__/settingsHeaderMeta.branchcov0713.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsHeaderMeta.branchcov0713.test.ts index 6b48cbe8a5..4b6b1ed925 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsHeaderMeta.branchcov0713.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsHeaderMeta.branchcov0713.test.ts @@ -165,9 +165,9 @@ describe('getSettingsHeaderMeta', () => { it('localizes system-relay to the English baseline', () => { expect(en['system-relay']).toEqual({ - title: 'Remote Access', + title: 'Pulse Mobile', description: - 'Check on your systems and get alert push notifications anywhere with the Pulse Mobile app — no port forwarding or VPN required.', + 'Connect paired Pulse Mobile devices to this instance. Pulse Mobile is being retired on 31 March 2027, and paired phones keep working until then.', }); }); @@ -440,9 +440,9 @@ describe('getSettingsHeaderMeta', () => { it('localizes system-relay', () => { expect(de['system-relay']).toEqual({ - title: 'Remote-Zugriff', + title: 'Pulse Mobile', description: - 'Behalten Sie Ihre Systeme von ueberall im Blick und erhalten Sie Alarm-Push-Benachrichtigungen ueber die Pulse-Mobile-App — ohne Portfreigaben oder VPN.', + 'Verbinden Sie gekoppelte Pulse-Mobile-Geraete mit dieser Instanz. Pulse Mobile wird am 31. Maerz 2027 eingestellt. Gekoppelte Telefone funktionieren bis dahin weiter.', }); }); @@ -639,9 +639,9 @@ describe('getSettingsHeaderMeta', () => { it('localizes system-relay', () => { expect(es['system-relay']).toEqual({ - title: 'Acceso remoto', + title: 'Pulse Mobile', description: - 'Consulta tus sistemas y recibe notificaciones push de alertas desde cualquier lugar con la aplicación Pulse Mobile — sin abrir puertos ni VPN.', + 'Conecta los dispositivos Pulse Mobile emparejados con esta instancia. Pulse Mobile se retirará el 31 de marzo de 2027 y los teléfonos emparejados seguirán funcionando hasta entonces.', }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts index 886a986e98..b2b3067191 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts @@ -56,21 +56,21 @@ describe('settings localization catalog', () => { expect(getSettingsShellCopy('es').mobileCloseLabel).toBe('Cerrar navegación de ajustes'); }); - it('localizes the value-first Remote Access header in every catalog locale', () => { + it('localizes the Pulse Mobile header in every catalog locale', () => { expect(SETTINGS_HEADER_META['system-relay']).toEqual({ - title: 'Remote Access', + title: 'Pulse Mobile', description: - 'Check on your systems and get alert push notifications anywhere with the Pulse Mobile app — no port forwarding or VPN required.', + 'Connect paired Pulse Mobile devices to this instance. Pulse Mobile is being retired on 31 March 2027, and paired phones keep working until then.', }); expect(getSettingsHeaderMeta('es')['system-relay']).toEqual({ - title: 'Acceso remoto', + title: 'Pulse Mobile', description: - 'Consulta tus sistemas y recibe notificaciones push de alertas desde cualquier lugar con la aplicación Pulse Mobile — sin abrir puertos ni VPN.', + 'Conecta los dispositivos Pulse Mobile emparejados con esta instancia. Pulse Mobile se retirará el 31 de marzo de 2027 y los teléfonos emparejados seguirán funcionando hasta entonces.', }); expect(getSettingsHeaderMeta('de')['system-relay']).toEqual({ - title: 'Remote-Zugriff', + title: 'Pulse Mobile', description: - 'Behalten Sie Ihre Systeme von ueberall im Blick und erhalten Sie Alarm-Push-Benachrichtigungen ueber die Pulse-Mobile-App — ohne Portfreigaben oder VPN.', + 'Verbinden Sie gekoppelte Pulse-Mobile-Geraete mit dieser Instanz. Pulse Mobile wird am 31. Maerz 2027 eingestellt. Gekoppelte Telefone funktionieren bis dahin weiter.', }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsNavigation.integration.test.tsx b/frontend-modern/src/components/Settings/__tests__/settingsNavigation.integration.test.tsx index 0ee91c25e5..fcd812d15a 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsNavigation.integration.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/settingsNavigation.integration.test.tsx @@ -151,7 +151,7 @@ describe('settingsNavigation integration scaffold', () => { ).toBe(false); }); - it('hides Remote Access navigation for free installs but keeps the direct route available', () => { + it('hides Pulse Mobile navigation for free installs but keeps the direct route available', () => { expect( shouldHideSettingsNavItem('system-relay', { hasFeature: hasFeatures([]), @@ -478,7 +478,7 @@ describe('settingsNavigation integration scaffold', () => { it('getTabLockReason returns tier-specific reason for locked tabs and null for unlocked', () => { const expectedTierLabels: Record = { - relay: 'Relay', + relay: 'Pro', multi_tenant: 'MSP', audit_logging: 'Pro', }; diff --git a/frontend-modern/src/components/Settings/__tests__/settingsReadOnlyPanels.test.tsx b/frontend-modern/src/components/Settings/__tests__/settingsReadOnlyPanels.test.tsx index 6a39a88e6f..9ce03cf585 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsReadOnlyPanels.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/settingsReadOnlyPanels.test.tsx @@ -141,7 +141,7 @@ describe('settings read-only panel states', () => { render(() => ); await waitFor(() => { - expect(screen.getByText(/Remote access settings are read-only/i)).toBeInTheDocument(); + expect(screen.getByText(/Pulse Mobile settings are read-only/i)).toBeInTheDocument(); }); const serverUrlInput = screen.getByDisplayValue('wss://relay.example.test/ws/instance'); diff --git a/frontend-modern/src/components/Settings/settingsHeaderMeta.ts b/frontend-modern/src/components/Settings/settingsHeaderMeta.ts index 408a27f97c..b4951cdabe 100644 --- a/frontend-modern/src/components/Settings/settingsHeaderMeta.ts +++ b/frontend-modern/src/components/Settings/settingsHeaderMeta.ts @@ -52,7 +52,7 @@ export const SETTINGS_HEADER_META: SettingsHeaderMetaMap = { 'Configure the model-backed service context Assistant and Patrol use. Infrastructure discovery and onboarding stay under Infrastructure.', }, 'system-relay': { - title: 'Remote Access', + title: 'Pulse Mobile', description: RELAY_SETTINGS_DESCRIPTION, }, 'system-billing': { diff --git a/frontend-modern/src/components/Settings/settingsNavCatalog.ts b/frontend-modern/src/components/Settings/settingsNavCatalog.ts index be83485a9d..ad3952278c 100644 --- a/frontend-modern/src/components/Settings/settingsNavCatalog.ts +++ b/frontend-modern/src/components/Settings/settingsNavCatalog.ts @@ -313,7 +313,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [ }, { id: 'system-relay', - label: 'Remote Access', + label: 'Pulse Mobile', icon: RadioTower, iconProps: { strokeWidth: 2 }, features: ['relay'], diff --git a/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts b/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts index 4407e09436..48a5646abc 100644 --- a/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts +++ b/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts @@ -162,13 +162,13 @@ export function useRelaySettingsPanelState(props: RelaySettingsPanelProps) { await loadConfig(); if (enabled) { startStatusPolling(); - showSuccess('Remote access enabled'); + showSuccess('Pulse Mobile connections on'); } else { stopStatusPolling(); setStatus(null); await deletePairingTokenIfUnused(pairingTokenId()); resetPairingState(); - showSuccess('Remote access disabled'); + showSuccess('Pulse Mobile connections off'); } } catch (error) { showError('Failed to update relay configuration'); diff --git a/frontend-modern/src/features/alerts/AlertPushDestinationsSection.test.tsx b/frontend-modern/src/features/alerts/AlertPushDestinationsSection.test.tsx index 213db72111..7bbd772daf 100644 --- a/frontend-modern/src/features/alerts/AlertPushDestinationsSection.test.tsx +++ b/frontend-modern/src/features/alerts/AlertPushDestinationsSection.test.tsx @@ -4,17 +4,10 @@ import type { JSX } from 'solid-js'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AlertPushDestinationsSection } from './AlertPushDestinationsSection'; import { - ALERT_DESTINATIONS_PUSH_GATE_MESSAGE, ALERT_DESTINATIONS_PUSH_MINIMUM_SEVERITY_HELP, ALERT_DESTINATIONS_PUSH_READY_MESSAGE, ALERT_DESTINATIONS_PUSH_SETUP_LINK_LABEL, } from '@/utils/alertDestinationsPresentation'; -import type { UpgradeDestination } from '@/utils/upgradeNavigation'; - -const upgradeDestination: UpgradeDestination = { - href: '/settings/pulse-intelligence/billing', - external: false, -}; function renderWithRouter(ui: () => JSX.Element) { return render(() => ( @@ -29,13 +22,11 @@ describe('AlertPushDestinationsSection', () => { cleanup(); }); - it('points licensed installs at Remote Access settings', () => { + it('points licensed installs at Pulse Mobile settings', () => { const onMinimumSeverityChange = vi.fn(); renderWithRouter(() => ( @@ -57,29 +48,11 @@ describe('AlertPushDestinationsSection', () => { expect(screen.getByText(ALERT_DESTINATIONS_PUSH_MINIMUM_SEVERITY_HELP)).toBeInTheDocument(); }); - it('shows the Relay upgrade gate to unlicensed installs', () => { - renderWithRouter(() => ( - - )); + it('renders nothing for installs without Pulse Mobile, since it is no longer sold', () => { + renderWithRouter(() => ); - expect(screen.getByText(ALERT_DESTINATIONS_PUSH_GATE_MESSAGE)).toBeInTheDocument(); expect(screen.queryByText(ALERT_DESTINATIONS_PUSH_READY_MESSAGE)).not.toBeInTheDocument(); - }); - - it('hides the upgrade call-to-action when upgrade prompts are suppressed', () => { - renderWithRouter(() => ( - - )); - - expect(screen.getByText(ALERT_DESTINATIONS_PUSH_GATE_MESSAGE)).toBeInTheDocument(); + expect(screen.queryByText(/Relay/)).not.toBeInTheDocument(); expect(screen.queryByRole('link')).not.toBeInTheDocument(); }); }); diff --git a/frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx b/frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx index a8fec92e39..66ecbe8b18 100644 --- a/frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx +++ b/frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx @@ -1,11 +1,6 @@ import { Show } from 'solid-js'; -import Smartphone from 'lucide-solid/icons/smartphone'; import { SettingsPanel } from '@/components/shared/SettingsPanel'; -import { FeatureGateSection } from '@/components/shared/FeatureGateSection'; -import type { UpgradeDestination } from '@/utils/upgradeNavigation'; import { - ALERT_DESTINATIONS_PUSH_GATE_MESSAGE, - ALERT_DESTINATIONS_PUSH_GATE_TITLE, ALERT_DESTINATIONS_PUSH_MINIMUM_SEVERITY_HELP, ALERT_DESTINATIONS_PUSH_PANEL_DESCRIPTION, ALERT_DESTINATIONS_PUSH_PANEL_TITLE, @@ -16,31 +11,20 @@ import { DestinationSeveritySelect } from '@/components/Alerts/DestinationSeveri interface AlertPushDestinationsSectionProps { relayLicensed: boolean; - showUpgradePrompts: boolean; - upgradeDestination: UpgradeDestination; minimumSeverity?: 'all' | 'critical'; onMinimumSeverityChange?: (value: 'all' | 'critical') => void; } +// Pulse Mobile is being retired on 31 March 2027 and is no longer sold, so the +// panel only appears on instances that already have it; there is no upsell. export function AlertPushDestinationsSection(props: AlertPushDestinationsSectionProps) { return ( - - } - title={ALERT_DESTINATIONS_PUSH_GATE_TITLE} - body={ALERT_DESTINATIONS_PUSH_GATE_MESSAGE} - upgradeDestination={props.upgradeDestination} - showUpgradePrompts={props.showUpgradePrompts} - /> - } + +

{ALERT_DESTINATIONS_PUSH_READY_MESSAGE}

@@ -60,7 +44,7 @@ export function AlertPushDestinationsSection(props: AlertPushDestinationsSection {ALERT_DESTINATIONS_PUSH_SETUP_LINK_LABEL} →
-
-
+ + ); } diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsState.test.tsx index 5dceee863a..77faffdf15 100644 --- a/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsState.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsState.test.tsx @@ -188,4 +188,29 @@ describe('useAlertDestinationsState', () => { expect(result.deadManPingUrl()).toBe(''); expect(result.pushMinimumSeverity()).toBe('all'); }); + + it('leaves Pulse Mobile push settings alone on instances without it', async () => { + // Pulse Mobile is being retired on 31 March 2027 and is no longer sold, so + // Community installs never load or write relay push settings. + const [activeTab] = createSignal<'overview' | 'destinations'>('destinations'); + vi.mocked(hasFeature).mockReturnValue(false); + vi.mocked(NotificationsAPI.getEmailConfig).mockResolvedValue({ enabled: false } as any); + vi.mocked(NotificationsAPI.getAppriseConfig).mockResolvedValue({ enabled: false } as any); + vi.mocked(NotificationsAPI.getWebhooks).mockResolvedValue([]); + vi.mocked(NotificationsAPI.updateEmailConfig).mockResolvedValue(undefined as any); + vi.mocked(NotificationsAPI.updateAppriseConfig).mockResolvedValue({ enabled: false } as any); + vi.mocked(AlertsAPI.getDeadManConfig).mockResolvedValue({ pingUrl: '', configured: false }); + vi.mocked(AlertsAPI.updateDeadManConfig).mockResolvedValue({ + success: true, + configured: false, + }); + + const { result } = renderHook(() => useAlertDestinationsState({ activeTab })); + + await result.loadDestinations(); + await result.saveDestinations(); + + expect(RelayAPI.getConfig).not.toHaveBeenCalled(); + expect(RelayAPI.updateConfig).not.toHaveBeenCalled(); + }); }); diff --git a/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx b/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx index 3bc3613965..3f2e0f7718 100644 --- a/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx +++ b/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx @@ -1,8 +1,6 @@ import { AlertQueueActionFeedback } from '../AlertQueueActionFeedback'; import { createMemo, createSignal, Show } from 'solid-js'; import { hasFeature } from '@/stores/license'; -import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; -import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; import { useAlertsActivation } from '@/stores/alertsActivation'; import { logger } from '@/utils/logger'; import type { AlertDestinationsDeliveryPausedReason } from '@/utils/alertDestinationsPresentation'; @@ -147,8 +145,6 @@ export function DestinationsTab(props: DestinationsTabProps) { { props.setPushMinimumSeverity(minimumSeverity); diff --git a/frontend-modern/src/i18n/messages.de.ts b/frontend-modern/src/i18n/messages.de.ts index 8433dfe98e..92aad8c3f5 100644 --- a/frontend-modern/src/i18n/messages.de.ts +++ b/frontend-modern/src/i18n/messages.de.ts @@ -488,8 +488,8 @@ export const DE_MESSAGE_OVERRIDES = { 'Verwalten Sie Backup-/Snapshot-Abfragen sowie Export- und Importablaeufe der Konfiguration.', 'settings.header.systemRecovery.title': 'Wiederherstellung', 'settings.header.systemRelay.description': - 'Behalten Sie Ihre Systeme von ueberall im Blick und erhalten Sie Alarm-Push-Benachrichtigungen ueber die Pulse-Mobile-App — ohne Portfreigaben oder VPN.', - 'settings.header.systemRelay.title': 'Remote-Zugriff', + 'Verbinden Sie gekoppelte Pulse-Mobile-Geraete mit dieser Instanz. Pulse Mobile wird am 31. Maerz 2027 eingestellt. Gekoppelte Telefone funktionieren bis dahin weiter.', + 'settings.header.systemRelay.title': 'Pulse Mobile', 'settings.header.systemUpdates.description': 'Suchen Sie nach neuen Pulse-Versionen, waehlen Sie einen Update-Kanal und verwalten Sie automatische Updates. Agent-Updates bleiben unter Infrastruktur.', 'settings.header.systemUpdates.title': 'Updates', @@ -529,7 +529,7 @@ export const DE_MESSAGE_OVERRIDES = { 'settings.nav.item.plans': 'Plaene & Abrechnung', 'settings.nav.item.providerModels': 'Anbieter & Modelle', 'settings.nav.item.recovery': 'Wiederherstellung', - 'settings.nav.item.remoteAccess': 'Remote-Zugriff', + 'settings.nav.item.remoteAccess': 'Pulse Mobile', 'settings.nav.item.resourcePrivacy': 'Ressourcenschutz', 'settings.nav.item.roles': 'Rollen', 'settings.nav.item.securityOverview': 'Sicherheitsuebersicht', diff --git a/frontend-modern/src/i18n/messages.es.ts b/frontend-modern/src/i18n/messages.es.ts index e3ae87ed7c..f8cb8c2f15 100644 --- a/frontend-modern/src/i18n/messages.es.ts +++ b/frontend-modern/src/i18n/messages.es.ts @@ -481,8 +481,8 @@ export const ES_MESSAGE_OVERRIDES = { 'Administra sondeos de copias/snapshots y flujos de exportación e importación de configuración.', 'settings.header.systemRecovery.title': 'Recuperación', 'settings.header.systemRelay.description': - 'Consulta tus sistemas y recibe notificaciones push de alertas desde cualquier lugar con la aplicación Pulse Mobile — sin abrir puertos ni VPN.', - 'settings.header.systemRelay.title': 'Acceso remoto', + 'Conecta los dispositivos Pulse Mobile emparejados con esta instancia. Pulse Mobile se retirará el 31 de marzo de 2027 y los teléfonos emparejados seguirán funcionando hasta entonces.', + 'settings.header.systemRelay.title': 'Pulse Mobile', 'settings.header.systemUpdates.description': 'Busca nuevas versiones de Pulse, elige un canal de actualización y administra las actualizaciones automáticas. Las actualizaciones de agentes permanecen en Infraestructura.', 'settings.header.systemUpdates.title': 'Actualizaciones', @@ -522,7 +522,7 @@ export const ES_MESSAGE_OVERRIDES = { 'settings.nav.item.plans': 'Planes y facturacion', 'settings.nav.item.providerModels': 'Proveedores y modelos', 'settings.nav.item.recovery': 'Recuperación', - 'settings.nav.item.remoteAccess': 'Acceso remoto', + 'settings.nav.item.remoteAccess': 'Pulse Mobile', 'settings.nav.item.resourcePrivacy': 'Privacidad de recursos', 'settings.nav.item.roles': 'Roles', 'settings.nav.item.securityOverview': 'Resumen de seguridad', diff --git a/frontend-modern/src/i18n/messages.ts b/frontend-modern/src/i18n/messages.ts index f6edd23d34..ab13ef5f47 100644 --- a/frontend-modern/src/i18n/messages.ts +++ b/frontend-modern/src/i18n/messages.ts @@ -471,8 +471,8 @@ export const EN_MESSAGES = { 'Manage backup/snapshot polling plus configuration export and import workflows.', 'settings.header.systemRecovery.title': 'Recovery', 'settings.header.systemRelay.description': - 'Check on your systems and get alert push notifications anywhere with the Pulse Mobile app — no port forwarding or VPN required.', - 'settings.header.systemRelay.title': 'Remote Access', + 'Connect paired Pulse Mobile devices to this instance. Pulse Mobile is being retired on 31 March 2027, and paired phones keep working until then.', + 'settings.header.systemRelay.title': 'Pulse Mobile', 'settings.header.systemUpdates.description': 'Check for new Pulse versions, choose an update channel, and manage automatic updates. Agent updates stay under Infrastructure.', 'settings.header.systemUpdates.title': 'Updates', @@ -512,7 +512,7 @@ export const EN_MESSAGES = { 'settings.nav.item.plans': 'Plans & Billing', 'settings.nav.item.providerModels': 'Provider & Models', 'settings.nav.item.recovery': 'Recovery', - 'settings.nav.item.remoteAccess': 'Remote Access', + 'settings.nav.item.remoteAccess': 'Pulse Mobile', 'settings.nav.item.resourcePrivacy': 'Resource Privacy', 'settings.nav.item.roles': 'Roles', 'settings.nav.item.securityOverview': 'Security Overview', diff --git a/frontend-modern/src/stores/__tests__/systemSettings.test.ts b/frontend-modern/src/stores/__tests__/systemSettings.test.ts index 13cdb09dd6..92dd021eb8 100644 --- a/frontend-modern/src/stores/__tests__/systemSettings.test.ts +++ b/frontend-modern/src/stores/__tests__/systemSettings.test.ts @@ -111,6 +111,8 @@ describe('systemSettings store', () => { 'utf8', ); + expect(privacyDoc).not.toContain('secure remote web access'); + expect(privacyDoc).toContain('Relay does not provide remote access to the web UI'); expect(privacyDoc).toContain('## Usage Data'); expect(privacyDoc).toContain('Pulse has one outbound usage-data scope'); expect(privacyDoc).toContain('Commercial activation and license-recovery runtime records'); diff --git a/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts b/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts index 441e4cf2e8..839f7bc178 100644 --- a/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts @@ -8,8 +8,6 @@ import { getAlertDestinationsDeliveryPausedDescription, getAlertDestinationsDeliveryPausedTitle, ALERT_DESTINATIONS_CONFIG_LOAD_ERROR, - ALERT_DESTINATIONS_PUSH_GATE_MESSAGE, - ALERT_DESTINATIONS_PUSH_GATE_TITLE, ALERT_DESTINATIONS_PUSH_PANEL_DESCRIPTION, ALERT_DESTINATIONS_PUSH_PANEL_TITLE, ALERT_DESTINATIONS_PUSH_READY_MESSAGE, @@ -146,16 +144,13 @@ describe('alertDestinationsPresentation', () => { 'Deliver alerts to your phone through the Pulse Mobile app.', ); expect(ALERT_DESTINATIONS_PUSH_READY_MESSAGE).toContain('Pulse Mobile devices paired'); - expect(ALERT_DESTINATIONS_PUSH_READY_MESSAGE).toContain('Remote Access settings'); + expect(ALERT_DESTINATIONS_PUSH_READY_MESSAGE).toContain('Pulse Mobile settings'); + expect(ALERT_DESTINATIONS_PUSH_READY_MESSAGE).toContain('retired on 31 March 2027'); expect(ALERT_DESTINATIONS_PUSH_MINIMUM_SEVERITY_HELP).toContain('Push copy stays private'); expect(ALERT_DESTINATIONS_PUSH_MINIMUM_SEVERITY_HELP).toContain( 'Open Pulse Mobile for current alert state', ); - expect(ALERT_DESTINATIONS_PUSH_SETUP_LINK_LABEL).toBe('Open Remote Access settings'); - expect(ALERT_DESTINATIONS_PUSH_GATE_TITLE).toBe('Get alerts on your phone'); - expect(ALERT_DESTINATIONS_PUSH_GATE_MESSAGE).toContain('Pulse Mobile app'); - expect(ALERT_DESTINATIONS_PUSH_GATE_MESSAGE).toContain('no port forwarding or VPN'); - expect(ALERT_DESTINATIONS_PUSH_GATE_MESSAGE).toContain('Available with Relay and Pro plans'); + expect(ALERT_DESTINATIONS_PUSH_SETUP_LINK_LABEL).toBe('Open Pulse Mobile settings'); }); it('distinguishes retained terminal failures from recoverable retry attempts', () => { diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index e7e4cded01..86f70f2d23 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -146,6 +146,18 @@ describe('docsLinks', () => { expect(apiReference).toContain('](#resource-maintenance-and-operator-state)'); }); + it('ships the Pulse Mobile retirement in the published API reference', () => { + const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8'); + const shippedAPIReference = readFileSync( + path.join(frontendRoot, 'public', 'docs', 'API.md'), + 'utf8', + ); + + expect(shippedAPIReference).toBe(apiReference); + expect(apiReference).toContain('## 📱 Relay / Pulse Mobile (retiring 31 March 2027)'); + expect(apiReference).not.toContain('Mobile Remote Access'); + }); + it('ships the per-alert snooze and resume API contract', () => { const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8'); const shippedAPIReference = readFileSync( diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.branchcov0723pm.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.branchcov0723pm.test.ts index 9c3506e1b6..d0c1989fd5 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.branchcov0723pm.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.branchcov0723pm.test.ts @@ -75,7 +75,7 @@ describe('patrol-control counts - fractional completed count truncated to 0', () runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Patrol Applies Safe Fixes and Verifies the Result', ], patrolOperatorStatus: { @@ -105,7 +105,7 @@ describe('patrol-control counts - negative counts clamped to 0', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Patrol Applies Safe Fixes and Verifies the Result', ], patrolOperatorStatus: { @@ -139,7 +139,7 @@ describe('getFirstPartyPatrolControlCount - alias subtraction underflow', () => runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Patrol Applies Safe Fixes and Verifies the Result', ], patrolOperatorStatus: { @@ -175,7 +175,7 @@ describe('hasVerifiedPatrolOperatorOutcome - resolved count without explicit val runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Patrol Applies Safe Fixes and Verifies the Result', ], patrolOperatorStatus: { diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.branchcov0725am.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.branchcov0725am.test.ts index c724789307..18d39a4484 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.branchcov0725am.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.branchcov0725am.test.ts @@ -52,7 +52,7 @@ describe('getSelfHostedCurrentPlanPresentation - absent subscription_state hits runtime: { build: 'pro', label: 'Pulse Pro runtime' }, } as unknown as LicenseCommercialEntitlements, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Patrol Applies Safe Fixes and Verifies the Result', ], }); @@ -134,7 +134,7 @@ describe('getSelfHostedCurrentPlanPresentation - absent tier (line 663)', () => limits: [], upgrade_reasons: [], } as unknown as LicenseCommercialEntitlements, - displayableCapabilities: ['Pulse Relay (Remote Access)'], + displayableCapabilities: ['Pulse Relay (Mobile Connection)'], }); // (current.tier || '') → '' → getSelfHostedPlanLabel(undefined) === 'Unknown'. expect(result.title).toBe('Current plan: Unknown'); @@ -167,7 +167,7 @@ describe('getSelfHostedCurrentPlanPresentation - empty extras on a runtime-misma upgrade_reasons: [], runtime: { build: 'community', label: 'Pulse Community runtime' }, }, - displayableCapabilities: ['Pulse Relay (Remote Access)', 'Audit Logging'], + displayableCapabilities: ['Pulse Relay (Mobile Connection)', 'Audit Logging'], }); // includedExtras.length === 0 → the `: undefined` arm of the ternary. expect(result.includedExtrasLabel).toBeUndefined(); @@ -214,8 +214,8 @@ describe('getSelfHostedPlanStatusPresentation - absent capabilities (line 861)', } as unknown as LicenseCommercialEntitlements); expect(result).not.toBeNull(); // (entitlements.capabilities || []) takes the [] arm → empty capability Set - // → the remote-access item is fully 'missing'. - const remoteAccess = result?.items.find((i) => i.label.includes('Remote access')); + // → the Pulse Mobile item is fully 'missing'. + const remoteAccess = result?.items.find((i) => i.label.includes('Pulse Mobile')); expect(remoteAccess).toMatchObject({ state: 'missing', statusLabel: 'Needs attention', @@ -258,7 +258,7 @@ describe('getSelfHostedActivationSuccessPresentation - absent tier (line 966)', limits: [], upgrade_reasons: [], } as unknown as LicenseCommercialEntitlements, - displayableCapabilities: ['Pulse Relay (Remote Access)'], + displayableCapabilities: ['Pulse Relay (Mobile Connection)'], source: 'manual', }); expect(result).not.toBeNull(); diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.coverage.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.coverage.test.ts index a9621a768f..fee4254245 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.coverage.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.coverage.test.ts @@ -99,9 +99,9 @@ describe('getFeatureMinTierLabel - edge cases', () => { expect(getFeatureMinTierLabel(undefined)).toBe('Pro'); }); - it('returns Relay for push_notifications and mobile_app', () => { - expect(getFeatureMinTierLabel('push_notifications')).toBe('Relay'); - expect(getFeatureMinTierLabel('mobile_app')).toBe('Relay'); + it('returns Pro for push_notifications and mobile_app now that Relay is retired', () => { + expect(getFeatureMinTierLabel('push_notifications')).toBe('Pro'); + expect(getFeatureMinTierLabel('mobile_app')).toBe('Pro'); }); }); @@ -430,14 +430,13 @@ describe('getBillingAdminTrialStatus - branch coverage', () => { * ------------------------------------------------------------------ */ describe('getSelfHostedPlanComparisonPresentation - edge cases', () => { - it('shows Relay and Pro cards for null / undefined entitlements', () => { + it('shows only the Pro card for null / undefined entitlements', () => { const result = getSelfHostedPlanComparisonPresentation({ entitlements: null }); - expect(result.cards).toHaveLength(2); - expect(result.cards[0].title).toBe('Relay plan'); - expect(result.cards[1].title).toBe('Pulse Pro plan'); + expect(result.cards).toHaveLength(1); + expect(result.cards[0].title).toBe('Pulse Pro plan'); }); - it('shows Relay and Pro cards for the community tier', () => { + it('shows only the Pro card for the community tier', () => { const result = getSelfHostedPlanComparisonPresentation({ entitlements: { tier: 'community', @@ -447,7 +446,7 @@ describe('getSelfHostedPlanComparisonPresentation - edge cases', () => { upgrade_reasons: [], }, }); - expect(result.cards.map((c) => c.title)).toEqual(['Relay plan', 'Pulse Pro plan']); + expect(result.cards.map((c) => c.title)).toEqual(['Pulse Pro plan']); }); it('returns no cards for an unrecognized tier', () => { @@ -523,7 +522,7 @@ describe('getSelfHostedCurrentPlanPresentation - trial branch', () => { upgrade_reasons: [], }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Push Notifications', ], @@ -562,7 +561,7 @@ describe('getSelfHostedCurrentPlanPresentation - is_lifetime branch', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Patrol Applies Safe Fixes and Verifies the Result', ], }); @@ -586,7 +585,7 @@ describe('getSelfHostedCurrentPlanPresentation - grace + grandfathered branch', runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Patrol Applies Safe Fixes and Verifies the Result', ], }); @@ -604,7 +603,7 @@ describe('getSelfHostedCurrentPlanPresentation - fallback branch', () => { limits: [], upgrade_reasons: [], }, - displayableCapabilities: ['Pulse Relay (Remote Access)'], + displayableCapabilities: ['Pulse Relay (Mobile Connection)'], }); expect(result.title).toBe('Current plan: Pulse Pro'); expect(result.body).toBe( @@ -626,7 +625,7 @@ describe('getSelfHostedCurrentPlanPresentation - getPatrolControlAction branches upgrade_reasons: [], runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, - displayableCapabilities: ['Pulse Relay (Remote Access)'], + displayableCapabilities: ['Pulse Relay (Mobile Connection)'], }); expect(result.patrolControlAction).toBeUndefined(); }); @@ -642,7 +641,7 @@ describe('getSelfHostedCurrentPlanPresentation - getPatrolControlAction branches runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Patrol Applies Safe Fixes and Verifies the Result', ], patrolOperatorStatus: { @@ -801,7 +800,7 @@ describe('getSelfHostedActivationSuccessPresentation - purchase + runtime mismat runtime: { build: 'community', label: 'Pulse Community runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Patrol Applies Safe Fixes and Verifies the Result', ], source: 'purchase', @@ -828,7 +827,7 @@ describe('getSelfHostedActivationSuccessPresentation - purchase without patrol a upgrade_reasons: [], }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Push Notifications', ], @@ -854,7 +853,7 @@ describe('getSelfHostedActivationSuccessPresentation - manual + runtime mismatch limits: [], upgrade_reasons: [], }, - displayableCapabilities: ['Pulse Relay (Remote Access)', 'Audit Logging'], + displayableCapabilities: ['Pulse Relay (Mobile Connection)', 'Audit Logging'], source: 'manual', }); expect(result).not.toBeNull(); diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts index 149359177c..b8961ae8a6 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts @@ -111,7 +111,7 @@ describe('licensePresentation', () => { expect(getLicenseFeatureLabel('ai_patrol')).toBe('Pulse Patrol'); expect(getLicenseFeatureLabel('mobile_app')).toBe('Pulse Mobile Pairing'); expect(getLicenseFeatureLabel('update_alerts')).toBe('Update Alerts'); - expect(getLicenseFeatureLabel('relay')).toBe('Pulse Relay (Remote Access)'); + expect(getLicenseFeatureLabel('relay')).toBe('Pulse Relay (Mobile Connection)'); expect(getLicenseFeatureLabel('custom_feature')).toBe('Custom Feature'); }); @@ -125,7 +125,7 @@ describe('licensePresentation', () => { }); it('returns minimum tier labels for gated features', () => { - expect(getFeatureMinTierLabel('relay')).toBe('Relay'); + expect(getFeatureMinTierLabel('relay')).toBe('Pro'); expect(getFeatureMinTierLabel('multi_tenant')).toBe('MSP'); expect(getFeatureMinTierLabel('unknown_feature')).toBe('Pro'); }); @@ -333,13 +333,13 @@ describe('licensePresentation', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Patrol Applies Safe Fixes and Verifies the Result', ], }), ).toMatchObject({ title: 'Current plan: Pulse Pro', - body: 'Pulse Pro is active on this instance. It includes Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.', + body: 'Pulse Pro is active on this instance. It includes Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, agent profiles, and Pulse Mobile pairing until 31 March 2027.', supplementalBadges: [], supplementalSummary: '', }); @@ -355,14 +355,14 @@ describe('licensePresentation', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Patrol Applies Safe Fixes and Verifies the Result', ], }), ).toEqual({ title: 'Current plan: Pulse Pro', - body: 'Pulse Pro is active on this instance. It includes Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.', + body: 'Pulse Pro is active on this instance. It includes Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, agent profiles, and Pulse Mobile pairing until 31 March 2027.', unlockedFeaturesLabel: 'Primary capabilities', unlockedFeatures: [ 'Patrol Investigates Issues and Explains the Root Cause', @@ -397,7 +397,7 @@ describe('licensePresentation', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Patrol Applies Safe Fixes and Verifies the Result', ], @@ -428,7 +428,7 @@ describe('licensePresentation', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Patrol Applies Safe Fixes and Verifies the Result', ], @@ -457,7 +457,7 @@ describe('licensePresentation', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Patrol Applies Safe Fixes and Verifies the Result', ], @@ -484,7 +484,7 @@ describe('licensePresentation', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Patrol Applies Safe Fixes and Verifies the Result', ], @@ -511,7 +511,7 @@ describe('licensePresentation', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Patrol Applies Safe Fixes and Verifies the Result', ], @@ -543,7 +543,7 @@ describe('licensePresentation', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Patrol Applies Safe Fixes and Verifies the Result', ], @@ -572,7 +572,7 @@ describe('licensePresentation', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Patrol Applies Safe Fixes and Verifies the Result', ], @@ -601,7 +601,7 @@ describe('licensePresentation', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Patrol Applies Safe Fixes and Verifies the Result', ], @@ -631,13 +631,13 @@ describe('licensePresentation', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Patrol Applies Safe Fixes and Verifies the Result', ], }), ).toMatchObject({ title: 'Current plan: Legacy Pulse Pro+', - body: 'Legacy Pulse Pro+ is active on this instance. It includes Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.', + body: 'Legacy Pulse Pro+ is active on this instance. It includes Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, agent profiles, and Pulse Mobile pairing until 31 March 2027.', unlockedFeaturesLabel: 'Primary capabilities', unlockedFeatures: [ 'Patrol Investigates Issues and Explains the Root Cause', @@ -657,11 +657,11 @@ describe('licensePresentation', () => { upgrade_reasons: [], runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, - displayableCapabilities: ['Pulse Relay (Remote Access)'], + displayableCapabilities: ['Pulse Relay (Mobile Connection)'], }), ).toEqual({ title: 'Current plan: Pulse Pro', - body: 'Pulse Pro is active on this instance. It includes Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.', + body: 'Pulse Pro is active on this instance. It includes Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, agent profiles, and Pulse Mobile pairing until 31 March 2027.', unlockedFeaturesLabel: 'Primary capabilities', unlockedFeatures: [ 'Patrol Investigates Issues and Explains the Root Cause', @@ -700,7 +700,7 @@ describe('licensePresentation', () => { expect( getSelfHostedCurrentPlanPresentation({ entitlements, - displayableCapabilities: ['Pulse Relay (Remote Access)', 'Audit Logging'], + displayableCapabilities: ['Pulse Relay (Mobile Connection)', 'Audit Logging'], }), ).toMatchObject({ title: 'Current plan: Pulse Pro', @@ -720,7 +720,7 @@ describe('licensePresentation', () => { expect( getSelfHostedActivationSuccessPresentation({ entitlements, - displayableCapabilities: ['Pulse Relay (Remote Access)', 'Audit Logging'], + displayableCapabilities: ['Pulse Relay (Mobile Connection)', 'Audit Logging'], source: 'manual', }), ).toMatchObject({ @@ -747,7 +747,7 @@ describe('licensePresentation', () => { expect( getSelfHostedCurrentPlanPresentation({ entitlements, - displayableCapabilities: ['Pulse Relay (Remote Access)', 'Audit Logging'], + displayableCapabilities: ['Pulse Relay (Mobile Connection)', 'Audit Logging'], }), ).toMatchObject({ title: 'Current plan: Pulse Pro', @@ -767,7 +767,7 @@ describe('licensePresentation', () => { expect( getSelfHostedActivationSuccessPresentation({ entitlements, - displayableCapabilities: ['Pulse Relay (Remote Access)', 'Audit Logging'], + displayableCapabilities: ['Pulse Relay (Mobile Connection)', 'Audit Logging'], source: 'manual', }), ).toMatchObject({ @@ -791,7 +791,7 @@ describe('licensePresentation', () => { upgrade_reasons: [], }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Push Notifications', ], @@ -809,7 +809,7 @@ describe('licensePresentation', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Patrol Applies Safe Fixes and Verifies the Result', ], }).body, @@ -834,7 +834,7 @@ describe('licensePresentation', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Patrol Applies Safe Fixes and Verifies the Result', ], }).patrolControlAction, @@ -878,7 +878,7 @@ describe('licensePresentation', () => { upgrade_reasons: [], }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Push Notifications', 'Patrol Applies Safe Fixes and Verifies the Result', @@ -887,7 +887,7 @@ describe('licensePresentation', () => { ).toBeUndefined(); }); - it('builds restrained higher-tier comparison cards for Community and Relay only', () => { + it('offers Community only the Pro card and never offers the retired Relay plan', () => { expect( getSelfHostedPlanComparisonPresentation({ entitlements: { @@ -900,23 +900,10 @@ describe('licensePresentation', () => { }), ).toEqual({ cards: [ - { - title: 'Relay plan', - body: 'Remote web access, Pulse Mobile pairing, push notifications, and 14-day metric history.', - highlights: [ - 'Secure access when you are away from your network', - 'Remote web access via Relay', - 'Pulse Mobile pairing', - 'Push notifications', - 'No inbound ports required', - '14-day metric history', - ], - }, { title: 'Pulse Pro plan', - body: 'Patrol investigates issues, applies safe fixes, and verifies the result. Relay connectivity is included, plus 90-day metric history and team controls.', + body: 'Patrol investigates issues, applies safe fixes, and verifies the result, with 90-day metric history and team controls.', highlights: [ - 'Relay connectivity, Pulse Mobile pairing, and push notifications included', 'Patrol modes: Ask first, Safe auto-fix, or Autopilot', 'Patrol investigates issues and explains the root cause', 'Patrol applies safe fixes and verifies the result', @@ -937,22 +924,7 @@ describe('licensePresentation', () => { upgrade_reasons: [], }, }), - ).toEqual({ - cards: [ - { - title: 'Pulse Pro plan', - body: 'Patrol investigates issues, applies safe fixes, and verifies the result. Relay connectivity is included, plus 90-day metric history and team controls.', - highlights: [ - 'Relay connectivity, Pulse Mobile pairing, and push notifications included', - 'Patrol modes: Ask first, Safe auto-fix, or Autopilot', - 'Patrol investigates issues and explains the root cause', - 'Patrol applies safe fixes and verifies the result', - '90-day metric history', - 'Team controls: RBAC, audit logging, reporting, and agent profiles', - ], - }, - ], - }); + ).toEqual({ cards: [] }); expect( getSelfHostedPlanComparisonPresentation({ @@ -982,11 +954,11 @@ describe('licensePresentation', () => { body: 'These checks show the capabilities this instance can use right now, based on its entitlement and runtime payloads.', items: [ { - label: 'Remote access, pairing, and push', + label: 'Pulse Mobile pairing and push', statusLabel: 'Active', state: 'active', detail: - 'Relay, Pulse Mobile pairing, and push notifications are available on this instance.', + 'Pulse Mobile pairing and push notifications are available on this instance until Pulse Mobile is retired on 31 March 2027.', }, { label: '14-day metric history', @@ -1024,7 +996,7 @@ describe('licensePresentation', () => { body: 'Open this only when a Pro capability looks unavailable. Normal setup is choosing Patrol mode.', items: [ { label: 'Pulse Pro runtime', statusLabel: 'Active' }, - { label: 'Remote access, pairing, and push', statusLabel: 'Active' }, + { label: 'Pulse Mobile pairing and push', statusLabel: 'Active' }, { label: '90-day metric history', statusLabel: 'Active' }, { label: 'Patrol investigation and remediation', statusLabel: 'Active' }, { label: 'Team controls', statusLabel: 'Active' }, @@ -1042,7 +1014,7 @@ describe('licensePresentation', () => { }); expect(partialProStatus?.items.map((item) => [item.label, item.statusLabel])).toEqual([ ['Pulse Pro runtime', 'Active'], - ['Remote access, pairing, and push', 'Partial'], + ['Pulse Mobile pairing and push', 'Partial'], ['90-day metric history', 'Partial'], ['Patrol investigation and remediation', 'Partial'], ['Team controls', 'Needs attention'], @@ -1099,7 +1071,7 @@ describe('licensePresentation', () => { const expectedCapabilityItems = [ ['Pulse Pro runtime', 'Active'], - ['Remote access, pairing, and push', 'Active'], + ['Pulse Mobile pairing and push', 'Active'], ['90-day metric history', 'Active'], ['Patrol investigation and remediation', 'Active'], ['Team controls', 'Active'], @@ -1242,7 +1214,7 @@ describe('licensePresentation', () => { runtime: { build: 'pro', label: 'Pulse Pro runtime' }, }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Push Notifications', 'Patrol Applies Safe Fixes and Verifies the Result', @@ -1279,7 +1251,7 @@ describe('licensePresentation', () => { upgrade_reasons: [], }, displayableCapabilities: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Push Notifications', ], @@ -1291,7 +1263,7 @@ describe('licensePresentation', () => { body: 'The license key was accepted and this instance is now running Relay.', highlightsLabel: 'Available now on this instance', highlights: [ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Push Notifications', '14-day metric history', diff --git a/frontend-modern/src/utils/__tests__/relayPresentation.test.ts b/frontend-modern/src/utils/__tests__/relayPresentation.test.ts index 412518d520..e71e67d92f 100644 --- a/frontend-modern/src/utils/__tests__/relayPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/relayPresentation.test.ts @@ -101,17 +101,14 @@ describe('relayPresentation', () => { }); it('centralizes relay availability copy', () => { - // Value-first copy: every surface states the user job (push notifications - // through the Pulse Mobile app, no port forwarding/VPN), not transport - // mechanics. - expect(RELAY_SETTINGS_DESCRIPTION).toContain('Pulse Mobile app'); - expect(RELAY_SETTINGS_DESCRIPTION).toContain('push notifications'); - expect(RELAY_SETTINGS_DESCRIPTION).toContain('no port forwarding or VPN'); - expect(RELAY_LICENSE_REQUIRED_MESSAGE).toContain('Pulse Mobile app'); - expect(RELAY_LICENSE_REQUIRED_MESSAGE).toContain('push notifications'); - expect(RELAY_LICENSE_REQUIRED_MESSAGE).toContain('Available with Relay and Pro plans'); - expect(RELAY_LICENSE_REQUIRED_MESSAGE).not.toContain('Relay or Pro'); - expect(RELAY_PAIRING_AVAILABILITY_TITLE).toBe('Pair Pulse Mobile through Relay'); + // Pulse Mobile retires on 31 March 2027 and Relay is no longer sold, so the + // copy states the end date and never offers Relay as a plan. + expect(RELAY_SETTINGS_DESCRIPTION).toContain('Pulse Mobile'); + expect(RELAY_SETTINGS_DESCRIPTION).toContain('retired on 31 March 2027'); + expect(RELAY_LICENSE_REQUIRED_MESSAGE).toContain('retired on 31 March 2027'); + expect(RELAY_LICENSE_REQUIRED_MESSAGE).toContain('ntfy, Gotify, or Pushover'); + expect(RELAY_LICENSE_REQUIRED_MESSAGE).not.toContain('Available with Relay'); + expect(RELAY_PAIRING_AVAILABILITY_TITLE).toBe('Pair Pulse Mobile'); expect(RELAY_PAIRING_AVAILABILITY_MESSAGE).toContain('QR code'); expect(RELAY_PAIRING_AVAILABILITY_MESSAGE).toContain('deep link'); expect(RELAY_PAIRING_AVAILABILITY_MESSAGE).toContain('push notifications'); @@ -119,7 +116,7 @@ describe('relayPresentation', () => { expect(RELAY_ENABLE_HELP_TEXT).toContain('No inbound ports'); expect(RELAY_ACTIVATION_REQUIRED_LABEL).toBe('Activation required'); expect(RELAY_ACTIVATION_REQUIRED_MESSAGE).toContain('active Relay token'); - expect(RELAY_ACTIVATION_REQUIRED_MESSAGE).toContain('Relay-capable plan'); + expect(RELAY_ACTIVATION_REQUIRED_MESSAGE).not.toContain('Relay-capable plan'); }); it('does not retain retired Relay price or trial-era onboarding copy', () => { diff --git a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts index c54bcb00c7..1007428f01 100644 --- a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts +++ b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts @@ -50,7 +50,7 @@ describe('selfHostedPlans', () => { }, { key: 'relay', - name: 'Pulse Relay (Remote Access)', + name: 'Pulse Relay (Mobile Connection)', community: false, relay: true, pro: true, @@ -104,23 +104,30 @@ describe('selfHostedPlans', () => { ]); expect(SELF_HOSTED_PLAN_BY_TIER.community.includedExtras).toEqual([]); expect(SELF_HOSTED_PLAN_BY_TIER.relay.billingExtrasSummary).toBe( - 'Remote web access, pairing, and push', + 'Pulse Mobile pairing and push', ); expect(SELF_HOSTED_PLAN_BY_TIER.relay.entitlementSummary).toContain( - 'remote web access, Pulse Mobile pairing, push notifications, and 14-day metric history.', + 'Pulse Mobile pairing and push notifications until 31 March 2027, and 14-day metric history.', ); expect(SELF_HOSTED_PLAN_BY_TIER.relay.comparisonSummary).toBe( - 'Remote web access, Pulse Mobile pairing, push notifications, and 14-day metric history.', + 'Retired plan: Pulse Mobile pairing and push notifications until 31 March 2027, and 14-day metric history.', ); + // Relay never gave remote web access; no plan copy may claim it. + for (const plan of Object.values(SELF_HOSTED_PLAN_BY_TIER)) { + expect( + `${plan.entitlementSummary} ${plan.comparisonSummary} ${plan.highlights.join(' ')}`, + ).not.toMatch(/remote web access/i); + } expect(SELF_HOSTED_PLAN_BY_TIER.relay.entitlementHighlights).toEqual([ - 'Pulse Relay (Remote Access)', + 'Pulse Relay (Mobile Connection)', 'Pulse Mobile Pairing', 'Push Notifications', '14-day metric history', ]); expect(SELF_HOSTED_PLAN_BY_TIER.relay.includedExtras).toEqual([]); - expect(SELF_HOSTED_PLAN_BY_TIER.relay.highlights).toContain('Pulse Mobile pairing'); - expect(SELF_HOSTED_PLAN_BY_TIER.relay.highlights).toContain('No inbound ports required'); + expect(SELF_HOSTED_PLAN_BY_TIER.relay.highlights).toContain( + 'Pulse Mobile pairing until 31 March 2027', + ); expect(SELF_HOSTED_PLAN_BY_TIER.relay.highlights.join('\n')).not.toMatch( /yourlab\.pulserelay\.pro|custom\s+(?:url|subdomain|domain)/i, ); @@ -128,10 +135,10 @@ describe('selfHostedPlans', () => { 'Patrol modes, history, and team controls', ); expect(SELF_HOSTED_PLAN_BY_TIER.pro.entitlementSummary).toContain( - 'Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.', + 'Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, agent profiles, and Pulse Mobile pairing until 31 March 2027.', ); expect(SELF_HOSTED_PLAN_BY_TIER.pro.comparisonSummary).toBe( - 'Patrol investigates issues, applies safe fixes, and verifies the result. Relay connectivity is included, plus 90-day metric history and team controls.', + 'Patrol investigates issues, applies safe fixes, and verifies the result, with 90-day metric history and team controls.', ); expect(SELF_HOSTED_PLAN_BY_TIER.pro.comparisonSummary).not.toContain('how much control'); expect(SELF_HOSTED_PLAN_BY_TIER.pro.entitlementHighlights).toEqual([ @@ -172,7 +179,7 @@ describe('selfHostedPlans', () => { getSelfHostedPlanEntitlementSummary('pro'), ); expect(getSelfHostedPlanEntitlementSummary('pro', 'Legacy Pulse Pro+')).toBe( - 'Legacy Pulse Pro+ is active on this instance. It includes Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.', + 'Legacy Pulse Pro+ is active on this instance. It includes Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, agent profiles, and Pulse Mobile pairing until 31 March 2027.', ); expect(SELF_HOSTED_PLAN_BY_TIER.relay.entitlementHighlights).toEqual( @@ -215,7 +222,7 @@ describe('selfHostedPlans', () => { { tier: 'relay', historyDays: 14, - extras: 'Remote web access, pairing, and push', + extras: 'Pulse Mobile pairing and push', }, { tier: 'pro', historyDays: 90, extras: 'Patrol modes, history, and team controls' }, ]); diff --git a/frontend-modern/src/utils/alertDestinationsPresentation.ts b/frontend-modern/src/utils/alertDestinationsPresentation.ts index 0498fe6c0b..92ad797dc7 100644 --- a/frontend-modern/src/utils/alertDestinationsPresentation.ts +++ b/frontend-modern/src/utils/alertDestinationsPresentation.ts @@ -76,13 +76,10 @@ export const ALERT_DESTINATIONS_PUSH_PANEL_TITLE = 'Mobile push notifications'; export const ALERT_DESTINATIONS_PUSH_PANEL_DESCRIPTION = 'Deliver alerts to your phone through the Pulse Mobile app.'; export const ALERT_DESTINATIONS_PUSH_READY_MESSAGE = - 'Alerts are pushed to Pulse Mobile devices paired with this instance. Manage pairing and connectivity in Remote Access settings.'; + 'Alerts are pushed to Pulse Mobile devices paired with this instance until Pulse Mobile is retired on 31 March 2027. Manage pairing in Pulse Mobile settings.'; export const ALERT_DESTINATIONS_PUSH_MINIMUM_SEVERITY_HELP = 'Choose whether phones receive warning and critical pushes or critical pushes only. Push copy stays private. Open Pulse Mobile for current alert state.'; -export const ALERT_DESTINATIONS_PUSH_SETUP_LINK_LABEL = 'Open Remote Access settings'; -export const ALERT_DESTINATIONS_PUSH_GATE_TITLE = 'Get alerts on your phone'; -export const ALERT_DESTINATIONS_PUSH_GATE_MESSAGE = - 'Pair the Pulse Mobile app to receive alert push notifications and check on your systems from anywhere — no port forwarding or VPN required. Available with Relay and Pro plans.'; +export const ALERT_DESTINATIONS_PUSH_SETUP_LINK_LABEL = 'Open Pulse Mobile settings'; export const ALERT_DESTINATIONS_APPRISE_TEST_SUCCESS = 'Test Apprise notification sent.'; export const ALERT_DESTINATIONS_APPRISE_TEST_FAILURE = 'Unable to send the test notification.'; export const ALERT_DESTINATIONS_DELIVERY_DEGRADED_TITLE = 'Notification delivery needs attention'; diff --git a/frontend-modern/src/utils/licensePresentation.ts b/frontend-modern/src/utils/licensePresentation.ts index f892631f5c..12ab51e914 100644 --- a/frontend-modern/src/utils/licensePresentation.ts +++ b/frontend-modern/src/utils/licensePresentation.ts @@ -39,10 +39,12 @@ const LEGACY_PLAN_VERSION_LABELS: Record = { pro_plus: 'Legacy Pro Plus', }; +// Relay was retired on 29 September 2026; Pro is the lowest plan that still +// carries these capabilities until Pulse Mobile retires on 31 March 2027. const FEATURE_MIN_TIER_LABELS: Record = { - relay: 'Relay', - mobile_app: 'Relay', - push_notifications: 'Relay', + relay: 'Pro', + mobile_app: 'Pro', + push_notifications: 'Pro', multi_tenant: 'MSP', }; @@ -614,12 +616,10 @@ export const getSelfHostedPlanComparisonPresentation = ({ entitlements?: LicenseCommercialEntitlements | null; }): SelfHostedPlanComparisonPresentation => { const normalizedTier = (entitlements?.tier || '').trim().toLowerCase(); + // Relay was retired on 29 September 2026: it is never offered, and existing + // Relay subscribers already carry Pro entitlements, so they see no upsell. const comparisonTiers = - normalizedTier === 'relay' - ? ['pro'] - : normalizedTier === 'free' || normalizedTier === 'community' || !normalizedTier - ? ['relay', 'pro'] - : []; + normalizedTier === 'free' || normalizedTier === 'community' || !normalizedTier ? ['pro'] : []; return { cards: comparisonTiers @@ -876,14 +876,14 @@ export const getSelfHostedPlanStatusPresentation = ( items.push( buildCapabilityStatusItem({ capabilities, - label: 'Remote access, pairing, and push', + label: 'Pulse Mobile pairing and push', requiredCapabilities: ['relay', 'mobile_app', 'push_notifications'], activeDetail: - 'Relay, Pulse Mobile pairing, and push notifications are available on this instance.', + 'Pulse Mobile pairing and push notifications are available on this instance until Pulse Mobile is retired on 31 March 2027.', partialDetail: - 'Some remote-access capabilities are available. Refresh the plan or open recovery if remote access, Pulse Mobile pairing, or push stays unavailable.', + 'Some Pulse Mobile capabilities are available. Refresh the plan or open recovery if Pulse Mobile pairing or push stays unavailable.', missingDetail: - 'Remote access, Pulse Mobile pairing, or push notifications are not available yet. Refresh the plan or open recovery before relying on Relay.', + 'Pulse Mobile pairing or push notifications are not available yet. Refresh the plan or open recovery before relying on Pulse Mobile.', }), ); diff --git a/frontend-modern/src/utils/relayPresentation.ts b/frontend-modern/src/utils/relayPresentation.ts index 6af39f5586..4131602f2b 100644 --- a/frontend-modern/src/utils/relayPresentation.ts +++ b/frontend-modern/src/utils/relayPresentation.ts @@ -30,19 +30,19 @@ export const RELAY_QR_IMAGE_CLASS = 'rounded-md border border-border p-2'; export const RELAY_DIAGNOSTICS_WRAP_CLASS = 'space-y-2'; export const RELAY_DIAGNOSTICS_TITLE_CLASS = 'text-xs font-semibold text-base-content'; export const RELAY_SETTINGS_DESCRIPTION = - 'Check on your systems and get alert push notifications anywhere with the Pulse Mobile app — no port forwarding or VPN required.'; + 'Connect paired Pulse Mobile devices to this instance. Pulse Mobile is being retired on 31 March 2027, and paired phones keep working until then.'; export const RELAY_LICENSE_REQUIRED_MESSAGE = - 'See your infrastructure and get alert push notifications anywhere with the Pulse Mobile app — no port forwarding or VPN required. Available with Relay and Pro plans.'; -export const RELAY_PAIRING_AVAILABILITY_TITLE = 'Pair Pulse Mobile through Relay'; + 'Pulse Mobile is being retired on 31 March 2027 and is no longer part of new plans. For alerts on your phone, add an ntfy, Gotify, or Pushover destination under Alerts.'; +export const RELAY_PAIRING_AVAILABILITY_TITLE = 'Pair Pulse Mobile'; export const RELAY_PAIRING_AVAILABILITY_MESSAGE = - 'Install the Pulse Mobile app on your phone, then scan a QR code or open a deep link here to securely link it to this Pulse instance. Paired devices receive alert push notifications over an end-to-end encrypted connection.'; + 'Install the Pulse Mobile app on your phone, then scan a QR code or open a deep link here to securely link it to this Pulse instance. Paired devices receive alert push notifications over an end-to-end encrypted connection until Pulse Mobile is retired on 31 March 2027.'; export const RELAY_ENABLE_HELP_TEXT = 'Open a secure outbound connection so paired Pulse Mobile devices can reach this instance from anywhere. No inbound ports needed.'; export const RELAY_ACTIVATION_REQUIRED_LABEL = 'Activation required'; export const RELAY_ACTIVATION_REQUIRED_MESSAGE = - 'Remote Access is enabled, but this instance does not have an active Relay token. Activate a Relay-capable plan or turn Remote Access off before pairing mobile clients.'; + 'Pulse Mobile connections are on, but this instance does not have an active Relay token. Refresh your plan, or turn Pulse Mobile connections off before pairing phones.'; export const RELAY_PAIRING_APP_AVAILABILITY_TEXT = - 'Pulse Mobile is in early access for iOS and Android. To get the install links, enter your activation key on'; + 'Pulse Mobile stays available for iOS and Android until 31 March 2027. To get the install links, enter your activation key on'; export const RELAY_PAIRING_APP_DOWNLOAD_LINK_LABEL = 'your download page'; export function getRelayDiagnosticClass(severity: 'warning' | 'error'): string { diff --git a/frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts b/frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts index 14ebec60e1..af39a245ab 100644 --- a/frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts +++ b/frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts @@ -76,8 +76,8 @@ export const SELF_HOSTED_FEATURE_CATALOG: readonly GeneratedSelfHostedFeatureCat }, { key: 'relay', - displayName: 'Pulse Relay (Remote Access)', - comparisonName: 'Pulse Relay (Remote Access)', + displayName: 'Pulse Relay (Mobile Connection)', + comparisonName: 'Pulse Relay (Mobile Connection)', showInComparisonTable: true, displayableInSelfHostedPlan: true, roles: { diff --git a/frontend-modern/src/utils/selfHostedPlans.ts b/frontend-modern/src/utils/selfHostedPlans.ts index 71698fc20c..4ff895c590 100644 --- a/frontend-modern/src/utils/selfHostedPlans.ts +++ b/frontend-modern/src/utils/selfHostedPlans.ts @@ -81,9 +81,9 @@ export function getSelfHostedPlanEntitlementSummary( case 'community': return `${planLabel} is active on this instance. It includes self-hosted monitoring, 7-day metric history, watch-only Patrol, update alerts, and SSO.`; case 'relay': - return `${planLabel} is active on this instance. It includes remote web access, Pulse Mobile pairing, push notifications, and 14-day metric history.`; + return `${planLabel} is active on this instance. It includes Pulse Mobile pairing and push notifications until 31 March 2027, and 14-day metric history.`; case 'pro': - return `${planLabel} is active on this instance. It includes Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.`; + return `${planLabel} is active on this instance. It includes Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, agent profiles, and Pulse Mobile pairing until 31 March 2027.`; } } @@ -129,18 +129,15 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = price: '$39/year', subline: 'or $4.99/month', metricHistoryDays: 14, - billingExtrasSummary: 'Remote web access, pairing, and push', + billingExtrasSummary: 'Pulse Mobile pairing and push', entitlementSummary: getSelfHostedPlanEntitlementSummary('relay'), entitlementHighlights: getTierEntitlementHighlights('relay', 14), includedExtras: [], comparisonSummary: - 'Remote web access, Pulse Mobile pairing, push notifications, and 14-day metric history.', + 'Retired plan: Pulse Mobile pairing and push notifications until 31 March 2027, and 14-day metric history.', highlights: [ - 'Secure access when you are away from your network', - 'Remote web access via Relay', - 'Pulse Mobile pairing', + 'Pulse Mobile pairing until 31 March 2027', 'Push notifications', - 'No inbound ports required', '14-day metric history', ], }, @@ -155,9 +152,8 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = entitlementHighlights: getTierEntitlementHighlights('pro', 90), includedExtras: getTierIncludedExtras('pro'), comparisonSummary: - 'Patrol investigates issues, applies safe fixes, and verifies the result. Relay connectivity is included, plus 90-day metric history and team controls.', + 'Patrol investigates issues, applies safe fixes, and verifies the result, with 90-day metric history and team controls.', highlights: [ - 'Relay connectivity, Pulse Mobile pairing, and push notifications included', 'Patrol modes: Ask first, Safe auto-fix, or Autopilot', 'Patrol investigates issues and explains the root cause', 'Patrol applies safe fixes and verifies the result', diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 466f0d5336..612a8fc8b5 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -10008,7 +10008,7 @@ func TestContract_OnboardingNotReadyResponseJSONSnapshot(t *testing.T) { Code: "relay_registration_unavailable", Severity: "error", Field: "instance_id", - Message: "Remote Access is enabled, but this Pulse instance is not connected to the relay yet. Wait for the status to show Connected before generating a mobile pairing code.", + Message: "Pulse Mobile connections are on, but this Pulse instance is not connected to the relay yet. Wait for the status to show Connected before generating a pairing code.", }, }, } @@ -10022,7 +10022,7 @@ func TestContract_OnboardingNotReadyResponseJSONSnapshot(t *testing.T) { "code":"onboarding_not_ready", "error":"Pulse Mobile pairing is not ready yet.", "message":"Pulse Mobile pairing is not ready yet.", - "diagnostics":[{"code":"relay_registration_unavailable","severity":"error","message":"Remote Access is enabled, but this Pulse instance is not connected to the relay yet. Wait for the status to show Connected before generating a mobile pairing code.","field":"instance_id"}] + "diagnostics":[{"code":"relay_registration_unavailable","severity":"error","message":"Pulse Mobile connections are on, but this Pulse instance is not connected to the relay yet. Wait for the status to show Connected before generating a pairing code.","field":"instance_id"}] }` assertJSONSnapshot(t, got, want) diff --git a/internal/api/onboarding_handlers.go b/internal/api/onboarding_handlers.go index ab9125a538..f5ab6af455 100644 --- a/internal/api/onboarding_handlers.go +++ b/internal/api/onboarding_handlers.go @@ -307,7 +307,7 @@ func (r *Router) buildOnboardingPayload(req *http.Request, relayCfg *relay.Confi Code: "relay_disabled", Severity: "error", Field: "relay.enabled", - Message: "Remote Access is disabled. Turn on Remote Access and wait for the relay status to show Connected before pairing Pulse Mobile.", + Message: "Pulse Mobile connections are off. Turn them on in Settings > Pulse Mobile and wait for the status to show Connected before pairing a phone.", }) } if payload.InstanceID == "" { @@ -315,7 +315,7 @@ func (r *Router) buildOnboardingPayload(req *http.Request, relayCfg *relay.Confi Code: "relay_registration_unavailable", Severity: "error", Field: "instance_id", - Message: "Remote Access is enabled, but this Pulse instance is not connected to the relay yet. Wait for the status to show Connected before generating a mobile pairing code.", + Message: "Pulse Mobile connections are on, but this Pulse instance is not connected to the relay yet. Wait for the status to show Connected before generating a pairing code.", }) } if payload.AuthToken == "" { diff --git a/internal/license/coverage_additional_test.go b/internal/license/coverage_additional_test.go index 5c45773b1f..8b0edffa73 100644 --- a/internal/license/coverage_additional_test.go +++ b/internal/license/coverage_additional_test.go @@ -91,7 +91,7 @@ func TestGetFeatureDisplayNameCoversKnownFeaturesAndFallback(t *testing.T) { {feature: FeatureAuditLogging, want: "Audit Logging"}, {feature: FeatureSSO, want: "Core SSO (OIDC/SAML)"}, {feature: FeatureAdvancedSSO, want: "Multi-Provider SSO"}, - {feature: FeatureRelay, want: "Pulse Relay (Remote Access)"}, + {feature: FeatureRelay, want: "Pulse Relay (Mobile Connection)"}, {feature: FeatureMobileApp, want: "Pulse Mobile Pairing"}, {feature: FeaturePushNotifications, want: "Push Notifications"}, {feature: FeatureAdvancedReporting, want: "PDF/CSV Reporting"}, diff --git a/pkg/licensing/features_test.go b/pkg/licensing/features_test.go index 6e56e19e29..0c8babcc49 100644 --- a/pkg/licensing/features_test.go +++ b/pkg/licensing/features_test.go @@ -384,7 +384,7 @@ func TestGetFeatureDisplayName(t *testing.T) { {FeatureAuditLogging, "Audit Logging"}, {FeatureSSO, "Core SSO (OIDC/SAML)"}, {FeatureAdvancedSSO, "Multi-Provider SSO"}, - {FeatureRelay, "Pulse Relay (Remote Access)"}, + {FeatureRelay, "Pulse Relay (Mobile Connection)"}, {FeatureMobileApp, "Pulse Mobile Pairing"}, {FeaturePushNotifications, "Push Notifications"}, {FeatureAdvancedReporting, "PDF/CSV Reporting"}, @@ -428,7 +428,7 @@ func TestSelfHostedFeatureMetadataKeepsCanonicalPlanLabelsAndVisibility(t *testi if !ok { t.Fatalf("expected metadata for %q", FeatureRelay) } - if relay.ComparisonName != "Pulse Relay (Remote Access)" { + if relay.ComparisonName != "Pulse Relay (Mobile Connection)" { t.Fatalf("ComparisonName = %q, want canonical Relay marketing label", relay.ComparisonName) } if GetSelfHostedFeatureRole(FeatureRelay, TierRelay) != SelfHostedFeatureRolePrimaryPillar { diff --git a/pkg/licensing/self_hosted_feature_catalog.go b/pkg/licensing/self_hosted_feature_catalog.go index caaf663d8c..cf4014537f 100644 --- a/pkg/licensing/self_hosted_feature_catalog.go +++ b/pkg/licensing/self_hosted_feature_catalog.go @@ -92,8 +92,8 @@ var featureMetadataCatalog = map[string]FeatureMetadata{ }, FeatureRelay: { Key: FeatureRelay, - DisplayName: "Pulse Relay (Remote Access)", - ComparisonName: "Pulse Relay (Remote Access)", + DisplayName: "Pulse Relay (Mobile Connection)", + ComparisonName: "Pulse Relay (Mobile Connection)", ShowInComparisonTable: true, DisplayableInPlanUI: true, SelfHostedRoles: SelfHostedFeatureRoles{ From 72c0190e01dc5fbe935dcc5c32495d8d99810c35 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:44:48 +0100 Subject: [PATCH 3/5] Pin the corrected Relay wording in the privacy docs test The script smoke test still required "Pulse Mobile pairing for handoff" in PRIVACY.md, the wording the previous commit replaced when it removed the unbacked "secure remote web access" claim. Pin the new description and forbid the remote web access wording in both copies. --- scripts/tests/test_telemetry_adoption_report.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/scripts/tests/test_telemetry_adoption_report.py b/scripts/tests/test_telemetry_adoption_report.py index c9bd86c348..135465295d 100644 --- a/scripts/tests/test_telemetry_adoption_report.py +++ b/scripts/tests/test_telemetry_adoption_report.py @@ -2623,11 +2623,17 @@ class TelemetryAdoptionReportTest(unittest.TestCase): repo_root / "frontend-modern" / "public" / "docs" / "PRIVACY.md" ).read_text(encoding="utf-8") - expected = "Pulse Mobile pairing for handoff" - self.assertIn(expected, canonical) - self.assertIn(expected, bundled) - self.assertNotIn("mobile app pairing", canonical) - self.assertNotIn("mobile app pairing", bundled) + for expected in ( + "paired Pulse Mobile devices can reach this instance and receive push notifications", + "Relay does not provide remote access to the web UI", + ): + self.assertIn(expected, canonical) + self.assertIn(expected, bundled) + # Relay only ever connected the Pulse Mobile app; the privacy docs must + # not describe it as remote web access. + for forbidden in ("mobile app pairing", "secure remote web access"): + self.assertNotIn(forbidden, canonical) + self.assertNotIn(forbidden, bundled) def test_privacy_docs_disclose_derived_pulse_intelligence_reports(self) -> None: repo_root = Path(__file__).resolve().parents[2] From 341e8a7eb4959d255852f2d5f1d8720689dd01f7 Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Tue, 29 Sep 2026 15:26:24 +0100 Subject: [PATCH 4/5] Calibrate internal/api shard weights to CI and record per-test seconds The weights from #2324 came from one local run and left the api shards at 4.5, 8.8, 17.1 and 12.5 minutes on CI. Most of the 17.1 was not test time. That shard ran `-run . -skip <1231 names>`, and the test binary caches only its last compiled pattern, so alternating the two recompiled the 62 KB skip regex for every test and subtest (about 50 ms each under -race locally, 30s against 1.3s for 300 trivial tests). Two runs put that shard at 887s and 875s against roughly 450s of tests. The skip path now passes -skip alone. Every api shard now runs go test -json through .github/scripts/record-internal-api-test-seconds.py. It prints what plain go test would (package lines and the output of failing or unfinished tests), exits non-zero on any failure behind pipefail, and writes each top-level test's seconds to an internal-api-test-seconds-N artifact that is uploaded even when the shard fails. .github/scripts/refresh-internal-api-test-seconds.py rebuilds the weights file from those artifacts (gh run download, median across runs, DEFAULT_WEIGHT set to the mean of the unlisted tests, which the selector now reads from the file). Until those artifacts exist, the weights are a local -race -json run with each region of go test's order scaled to what CI reported for it. Equal-count quarters averaged 107, 437, 100 and 787s over four runs, and the weighted cut gave 131s for the first 1187 tests, 350s for the next 23 and 608s for the last 21 over two. The last quarter and the last 21 tests disagree on how the tail splits, so the tail takes the midpoint of an exact fit and a pooled factor. With these weights four shards still predict a slowest shard near 10 minutes, and a 20% slower runner on the tail would take it past the 11 minutes of Frontend and rest-1. Five shards predict about 131, 325, 280-340, 330-355 and 330-365s of tests, or 4.5 to 8.5 minutes a job with about 2.3 minutes of setup, so API_SHARD_COUNT goes to 5. Every test still runs exactly once in contiguous go test order, and all five slices passed locally from their new starting points. The required Backend tests (api) verdict keeps its name. --- .github/scripts/internal-api-test-seconds.txt | 427 +++++++++--------- .../record-internal-api-test-seconds.py | 104 +++++ .../refresh-internal-api-test-seconds.py | 155 +++++++ .github/scripts/select-internal-api-shard.sh | 14 +- .github/workflows/build-and-test.yml | 32 +- .../subsystems/deployment-installability.md | 41 +- .../v6/internal/subsystems/registry.json | 4 + .../installtests/build_release_assets_test.go | 87 +++- 8 files changed, 627 insertions(+), 237 deletions(-) create mode 100755 .github/scripts/record-internal-api-test-seconds.py create mode 100755 .github/scripts/refresh-internal-api-test-seconds.py diff --git a/.github/scripts/internal-api-test-seconds.txt b/.github/scripts/internal-api-test-seconds.txt index d3465b3b52..049f78cbac 100644 --- a/.github/scripts/internal-api-test-seconds.txt +++ b/.github/scripts/internal-api-test-seconds.txt @@ -1,212 +1,217 @@ -# Estimated -race run time in seconds of the slowest internal/api top-level -# tests on a GitHub ubuntu-24.04 runner. Used only by -# select-internal-api-shard.sh to choose where the Backend tests (api-N) -# shards cut go test's list. Tests not named here weigh DEFAULT_WEIGHT (0.05s, -# the measured mean of the unlisted tests). A missing, stale or renamed entry -# can make the shards uneven but can never drop or repeat a test. +# Run time in seconds of the slowest internal/api top-level tests under -race +# on a GitHub ubuntu-24.04 runner. Used only by select-internal-api-shard.sh +# to choose where the Backend tests (api-N) shards cut go test's list. Tests +# not named here weigh DEFAULT_WEIGHT, the mean of the tests under 0.5s. A +# missing, stale or renamed entry can make the shards uneven but can never +# drop or repeat a test. # -# Refresh when one api shard runs well over the others: run -# GOMAXPROCS=4 go test -race -json ./internal/api -# and list every top-level test at 0.5s or more. Seeded 2026-09-29 from such a -# local run scaled to the four shard totals of Build and Test run 36560070496 -# (the contract mock chart test runs about 1.4x slower on the runner, the -# wait-bound integration server tail about 0.8x). -TestActionRunnerRotationProductionRouterTLSPersistenceRestart 0.69 -TestMockActionInboxAndDetailUseCanonicalResponses 2.40 -TestMockActionInboxHonorsLimitAndLegacyPendingProjection 1.79 -TestMockActionMutationsFailReadOnly 0.98 -TestAgentReportHandlersClassifyBodyFailures 1.10 -TestStart_MockModeStartsChatServiceWhenPersistedConfigDisabled 0.95 -TestRestart_MockModeStartsChatServiceWhenPersistedConfigDisabled 0.95 -TestHandleChat_EmitsIdleProgressWhileServiceIsSilent 2.40 -TestAISettingsHandler_ProviderSettersConcurrentServiceCreation 8.31 -TestHandleSaveGuestNote_VerifySavedFields 0.52 -TestHandleClearGuestKnowledge_OversizedBody 0.57 -TestHandlePatrolStream_LastEventIDHeaderTakesPrecedenceOverQueryFallback 1.99 -TestHandleExecuteStream_LicenseRequired 1.05 -TestHandleExecuteStream_PromptRequired 1.06 -TestHandleExecuteStream_Success 1.80 -TestHandleExecuteStream_EmitsIdleProgressWhileServiceIsSilent 1.74 -TestHandleInvestigateAlert_EmitsIdleProgressWhileServiceIsSilent 1.69 -TestAISettingsHandler_PatrolReadinessBranches 0.85 -TestHandleInvestigateAlert_UsesVisibleIdleProgressStream 1.73 -TestHandleGetPatterns_UnlockedWithData 1.04 -TestHandleGetPredictions_WithData 1.04 -TestHandleGetCorrelations_WithData 1.05 -TestHandleGetCorrelations_UsesCanonicalIntelligenceFacade 1.04 -TestExtractAndStoreAuthContext_UsesTenantConfigForToken 2.98 -TestAuthDenialPruneKeepsTrackedSetBounded 1.92 -TestAvailabilityHandlersListReturnsMockTargetsInMockMode 1.03 -TestAvailabilityHandlersTestSavedMockTargetUsesSyntheticStatus 1.03 -TestChangePasswordRefusesNonAdminSession 0.83 -TestChangePasswordAllowsConfiguredAdminSession 1.52 -TestChangePasswordBasicAuthPathUnaffected 4.19 -TestChangePasswordFixtureOwnsRouterLifecycle 0.84 -TestConfigTransferAnonymousAuthenticatedModesDenyBeforeBodyRead 0.50 -TestConfigTransferAuthorizedInstanceModesReachHandler 0.70 -TestConnectionsHandleListIncludesMockAvailabilityTargets 1.13 -TestConnectionsHandleListDropsRealSourcesInMockMode 1.19 -TestConnectionsLedgerKeepsRealSourcesWhenRealPollingRetained 1.15 -TestContract_MockAvailabilityTargetsUseSavedTargetPayloads 1.13 -TestContract_MockDiscoveryEndpointsUseCanonicalPayloads 1.05 -TestContract_MockChartRoutesUseCanonicalMockUnifiedReadStateForVMwareHosts 391.48 -TestContract_PlatformMockToggleRebindsRuntimeConnectionsAndResources 19.96 -TestContract_PlatformMockConnectionListsUseSharedFixtureMetadata 1.08 -TestContract_RecoveryPointsMockPathReturnsCanonicalProviderBackedFixtures 1.61 -TestContract_SecurityStatusPresentationPolicyProEditionCommercialContext 0.50 -TestContract_StateAndResourceListShareCanonicalMockResourceContract 4.64 -TestContract_AgentCommandConfigGateMirrorsChannelAdmission 1.13 -TestContract_HostedDiagnosticsDockerPrepareTokenValidatesOriginBeforeMutation 1.22 -TestContract_HostedInstallerOriginsFailClosedAtRouter 4.04 -TestContract_RuntimeDisplayServesPresentationValuesWithoutAdmin 0.84 -TestSecurityStatusCurrentUserForScopedLocalSession 0.84 -TestSecurityStatusOrgBoundSettingsFollowOrgManagementRule 0.84 -TestSecurityStatusOrgBoundReportingRespectsAuthorizer 0.83 -TestDiscoveryHandlers_MockModeServesFixturesWithoutService 1.13 -TestDockerAgentReportSizeContractAcceptsOrdinaryAndLargeFleets 0.55 -TestDockerAgentReportSizeContractBoundaries 2.57 -TestCollectorUninstallTransactionFailsClosedAndRetriesAfterRestart 2.15 -TestHostedTenantAgentInstallCommand_GeneratesOrgBoundTokenAndCommand 3.13 -TestGenerateHostedTenantAgentInstallCommandEnforcesHostedOrgBoundary 1.11 -TestHostedTenantAgentInstallCommandCarriesCommandPolicyIntent 1.49 -TestHostedTenantAgentInstallTokenCannotReportToOtherTenant 0.50 -TestIssue1753SameNameProxmoxAgentsAuthenticateAndStayDistinctEndToEnd 2.20 -TestSyncReleaseDemoFixtureRuntime_DoesNotPoliceMockFixturesInDevBuilds 1.23 -TestMetricsHistoryFallbackSynthesizesUnseededMockWorkloadHistory 1.64 -TestMetricsHistoryFallbackSynthesizesMockHostAndNodeDrawerSeries 1.99 -TestMetricsHistoryFallbackSupplementsPartialMockStoreSeries 1.68 -TestMetricsHistoryFallbackMockDiskSynthesizesSeries 1.65 -TestMetricsHistoryFallbackMockUnifiedStorageSynthesizesSeries 2.27 -TestMetricsHistoryFallbackMockStorageBypassesSparseStoreSeries 2.47 -TestSSOOnlyExplicitAdminReachesEveryAdminGuard 1.42 -TestUnrelatedSSOUserStillRefusedWhenLocalAdminConfigured 2.10 -TestPatrolActionBrokerAutonomyModeIsAnUpperBound 0.53 -TestPlatformAdminRouteRefusesUnrelatedSSOUser 0.84 -TestJourneyGeneralAPIBudgetIsolation 1.75 -TestTenantRBACProvider_ConcurrentGetAndRemove 0.52 -TestTenantRBACProvider_NoConnectionLeak 1.43 -TestHandleListPointsAcceptsCanonicalPlatformQuery 1.82 -TestHandleListRollupsExposeCanonicalPlatformsPayload 1.86 -TestHandleListRollupsMetaReportsNormalizedPagination 2.71 -TestHandleListPointsMetaReportsNormalizedPagination 2.74 -TestStartPatrolForContext_DoesNotOverwriteOtherTenantPatrolComponents 2.30 -TestStartPatrolForContext_RejectsMismatchedAIServiceOrg 1.24 -TestRouterMockMode_SeedsTrueNASAndVMwareSupplementalResources 1.58 -TestRouterMockMode_SeedsVMwareSupplementalActivity 1.21 -TestRouteExecuteStream_Success 0.94 -TestRouteExecuteStream_MethodNotAllowed 0.76 -TestRouteExecuteStream_NoAuth 0.60 -TestRouteExecuteStream_WrongScope 0.65 -TestRouteExecuteStream_EmptyPrompt 0.83 -TestRouteExecuteStream_WhitespaceOnlyPrompt 0.73 -TestRouteExecuteStream_InvalidJSON 0.80 -TestRouteExecuteStream_AIDisabled 0.78 -TestRouteExecuteStream_AutofixLicenseRequired 0.75 -TestRouteExecuteStream_RemediationLicenseRequired 0.66 -TestRouteExecuteStream_OversizedBody 0.82 -TestRouteExecuteStream_UseCaseCaseInsensitive 0.86 -TestRouteExecuteStream_SSEHeaders 0.66 -TestRouteExecuteStream_CompleteEventBeforeDone 0.76 -TestRouteExecuteStream_EmptyBody 0.68 -TestRouteExecuteStream_NullPrompt 0.74 -TestRouteExecuteStream_EmptyJSONObject 0.55 -TestRouteExecuteStream_ServiceError 0.69 -TestRouteExecuteStream_InvalidTargetType 0.60 -TestRouteExecuteStream_InvalidHistoryRole 0.70 -TestRouteExecuteStream_TargetIDTooLong 0.58 -TestRouteExecuteStream_ValidTargetTypes 2.08 -TestRouteExecuteStream_EmptyTargetTypeAllowed 1.50 -TestRouteExecuteStream_WithConversationHistory 0.58 -TestRouteExecuteStream_WithTargetContext 0.75 -TestRouteExecuteStream_DefaultUseCaseIsChat 0.73 -TestRouteExecuteStream_FindingIDPassedThrough 0.73 -TestRouteExecuteStream_ModelFieldPassedThrough 0.63 -TestRouteExecute_Success 0.74 -TestRouteExecute_MethodNotAllowed 0.75 -TestRouteExecute_NoAuth 0.79 -TestRouteExecute_WrongScope 0.69 -TestRouteExecute_EmptyPrompt 0.74 -TestRouteExecute_InvalidJSON 0.75 -TestRouteExecute_WithConversationHistory 0.68 -TestRouteExecute_WithTargetContext 0.87 -TestRouteExecute_DefaultUseCaseIsChat 0.73 -TestRouteExecute_AutofixLicenseRequired 0.75 -TestRouteExecute_RemediationLicenseRequired 0.56 -TestRouteExecute_OversizedBody 0.93 -TestRouteExecute_EmptyBody 0.66 -TestRouteExecute_EmptyJSONObject 0.94 -TestRouteExecute_NullPrompt 0.75 -TestRouteExecute_ServiceError 0.73 -TestRouteExecute_InvalidTargetType 0.75 -TestRouteExecute_ValidTargetTypes 2.17 -TestRouteExecute_EmptyTargetTypeAllowed 0.75 -TestRouteExecute_InvalidHistoryRole 0.73 -TestRouteExecute_FindingIDPassedThrough 0.76 -TestRouteExecute_ModelFieldPassedThrough 0.73 -TestRouteListModels_Success 0.68 -TestRouteListModels_MethodNotAllowed 0.66 -TestRouteListModels_WrongScope 0.72 -TestRouteListModels_ProviderError 0.75 -TestRouteListModels_EmptyModelList 0.76 -TestRouteListModels_ResponseContentType 0.63 -TestRouteListModels_CachedField 0.76 -TestRouteListModels_MalformedProviderResponse 0.78 -TestRouteListModels_HEADMethod 0.87 -TestRouteTestConnection_Success 0.76 -TestRouteTestConnection_MethodNotAllowed 0.72 -TestRouteTestConnection_NoAuth 0.74 -TestRouteTestConnection_WrongScope 0.74 -TestRouteTestProvider_Success 0.72 -TestRouteTestProvider_UnconfiguredProvider 0.66 -TestRouteTestProvider_ConnectionFailure 0.60 -TestRouteTestProvider_MethodNotAllowed 0.76 -TestRouteTestProvider_NoAuth 0.75 -TestRouteTestProvider_MultipleProviders 0.74 -TestRouteTestProvider_UnknownProvider 0.60 -TestRouteTestProvider_WrongScope 0.75 -TestRouteTestProvider_InvalidProviderNames 0.74 -TestRouteTestProvider_PathTraversal 0.85 -TestRouter_HandleState_MockIsolation 4.14 -TestAITestConnectionRouteWithValidScope 0.75 -TestAITestConnectionRouteRejectsWrongScope 0.82 -TestAITestProviderRouteRejectsWrongScope 0.74 -TestPublicEndpointsBypassAuthInAPIMode 0.61 -TestSettingsCapabilitiesMatchRouteEnforcementWithoutRBAC 0.86 -TestSettingsCapabilitiesGrantConfiguredAdminWithoutRBAC 0.93 -TestTrueNASHandlers_HandleList_ReturnsMockConnectionsInMockMode 1.23 -TestFreeBSDOfflineAgentPreflight 1.83 -TestBranchcov0723AmExtractFromZip 8.86 -TestHandleApplyUpdate_BlocksWhenReadinessBlocked 1.47 -TestHandleGetUpdatePlan 1.65 -TestHandleGetUpdatePlan_IncludesUpgradeReadiness 1.34 -TestHandleGetUpdatePlan_InvalidChannel 1.09 -TestHandleGetUpdatePlan_PrepareError 1.43 -TestVMwareHandlers_HandleList_ReturnsMockConnectionsInMockMode 1.33 -TestAlertsEndpoints 20.56 -TestRecoveryRollupsEndpointToleratesMalformedPersistedMetadata 0.55 -TestHealthEndpoint 19.04 -TestVersionEndpointUsesRepoVersion 19.47 -TestAlertAcknowledge_AllowsPrintableAlertIDs 18.81 -TestStateEndpointReturnsMockData 20.72 -TestRecoveryRollupsEndpointReturnsMockData 20.65 -TestProtectedEndpointsRequireAuthentication 18.62 -TestAPIOnlyModeRequiresToken 21.79 -TestServerInfoEndpointReportsDevelopment 18.85 -TestRecoveryPointsEndpointReturnsMockData 21.75 -TestServerInfoEndpointMethodNotAllowed 18.65 -TestHealthEndpointMethodNotAllowed 18.76 -TestHealthEndpointHeadAllowed 18.68 -TestConfigNodesUsesMockTopology 17.84 -TestMockModeToggleEndpoint 25.43 -TestMockModeToggleFromRealDataRebindsPlatformSupplementalProviders 19.24 -TestAuthenticatedEndpointsRequireToken 19.40 -TestAPITokenQueryAndHeaderAuth 23.11 -TestRecoveryEndpointRequiresDirectLoopback 20.32 -TestWebSocketSendsInitialState 19.38 -TestWebsocketPayloadContractShape 17.62 -TestWebsocketPayloadUsesCanonicalStateContract 19.56 -TestSessionCookieAllowsAuthenticatedAccess 20.16 -TestRevokedAPITokenImmediatelyLosesAccess 24.33 -TestLimitedAPITokenCannotCreateBroaderToken 20.31 -TestPublicURLDetectionUsesForwardedHeaders 17.31 -TestPublicURLDetectionRespectsEnvOverride 19.14 +# Every api shard uploads an internal-api-test-seconds-N artifact with the +# seconds of each test it ran on the runner, so refresh this file from recent +# passing Build and Test runs with +# python3 .github/scripts/refresh-internal-api-test-seconds.py --run [--run ] +# when one api shard runs well over the others. +# +# Seeded 2026-09-29, before any such artifact existed, from a local +# GOMAXPROCS=4 go test -race -json run with each region of go test's order +# scaled so its sum matches the seconds the runner reported for it. +# Equal-count quarters (runs 36557693864, 36560070496, 36567708327, +# 36577491881) averaged 107s, 437s, 100s and 787s. The weighted cut (runs +# 36566872567, 36578019732) averaged 131s for the first 1187 tests, 350s for +# the next 23 and 608s for the last 21. Its 2556-test shard (887s, 875s) is +# left out, because it ran through -run . -skip, which recompiles the skip +# pattern for every test and subtest. The last quarter and the last 21 tests +# disagree on how the tail splits, so the two tail regions take the midpoint +# of an exact fit and one pooled factor. +DEFAULT_WEIGHT 0.05 +TestActionRunnerRotationProductionRouterTLSPersistenceRestart 0.82 +TestMockActionInboxAndDetailUseCanonicalResponses 2.85 +TestMockActionInboxHonorsLimitAndLegacyPendingProjection 2.12 +TestMockActionMutationsFailReadOnly 1.16 +TestAgentReportHandlersClassifyBodyFailures 1.31 +TestStart_MockModeStartsChatServiceWhenPersistedConfigDisabled 1.13 +TestRestart_MockModeStartsChatServiceWhenPersistedConfigDisabled 1.13 +TestHandleChat_EmitsIdleProgressWhileServiceIsSilent 2.85 +TestAISettingsHandler_ProviderSettersConcurrentServiceCreation 9.86 +TestHandleSaveGuestNote_VerifySavedFields 0.62 +TestHandleClearGuestKnowledge_OversizedBody 0.68 +TestHandlePatrolStream_LastEventIDHeaderTakesPrecedenceOverQueryFallback 2.37 +TestHandleExecuteStream_LicenseRequired 1.25 +TestHandleExecuteStream_PromptRequired 1.26 +TestHandleExecuteStream_Success 2.13 +TestHandleExecuteStream_EmitsIdleProgressWhileServiceIsSilent 2.06 +TestHandleInvestigateAlert_EmitsIdleProgressWhileServiceIsSilent 2.01 +TestAISettingsHandler_PatrolReadinessBranches 1.01 +TestAISettingsHandler_KeylessOpenAICompatibleSetupIsConfiguredAndValidated 0.51 +TestAISettingsHandler_TestProvider_ConnectionFailure 0.52 +TestHandleGetAICostSummary_MethodNotAllowed 0.54 +TestHandleInvestigateAlert_UsesVisibleIdleProgressStream 2.05 +TestHandleGetPatterns_UnlockedWithData 1.23 +TestHandleGetPredictions_WithData 1.23 +TestHandleGetCorrelations_WithData 1.25 +TestHandleGetCorrelations_UsesCanonicalIntelligenceFacade 1.24 +TestExtractAndStoreAuthContext_UsesTenantConfigForToken 3.54 +TestAuthDenialPruneKeepsTrackedSetBounded 2.28 +TestAvailabilityHandlersListReturnsMockTargetsInMockMode 1.22 +TestAvailabilityHandlersTestSavedMockTargetUsesSyntheticStatus 1.22 +TestChangePasswordRefusesNonAdminSession 0.90 +TestChangePasswordAllowsConfiguredAdminSession 1.66 +TestChangePasswordBasicAuthPathUnaffected 4.57 +TestChangePasswordFixtureOwnsRouterLifecycle 0.91 +TestConfigTransferAnonymousAuthenticatedModesDenyBeforeBodyRead 0.55 +TestConfigTransferAuthorizedInstanceModesReachHandler 0.77 +TestConfigTransferTenantSessionsRequireManagement 0.54 +TestConnectionsHandleListIncludesMockAvailabilityTargets 1.23 +TestConnectionsHandleListDropsRealSourcesInMockMode 1.30 +TestConnectionsLedgerKeepsRealSourcesWhenRealPollingRetained 1.26 +TestContract_MockAvailabilityTargetsUseSavedTargetPayloads 1.23 +TestContract_MockDiscoveryEndpointsUseCanonicalPayloads 1.15 +TestContract_MockChartRoutesUseCanonicalMockUnifiedReadStateForVMwareHosts 324.29 +TestContract_PlatformMockToggleRebindsRuntimeConnectionsAndResources 24.19 +TestContract_PlatformMockConnectionListsUseSharedFixtureMetadata 1.31 +TestContract_RecoveryPointsMockPathReturnsCanonicalProviderBackedFixtures 2.15 +TestContract_SecurityStatusPresentationPolicyProEditionCommercialContext 0.67 +TestContract_StateAndResourceListShareCanonicalMockResourceContract 6.16 +TestContract_AgentCommandConfigGateMirrorsChannelAdmission 1.50 +TestContract_HostedDiagnosticsDockerPrepareTokenValidatesOriginBeforeMutation 1.62 +TestContract_HostedInstallerOriginsFailClosedAtRouter 5.36 +TestContract_RuntimeDisplayServesPresentationValuesWithoutAdmin 1.11 +TestSecurityStatusCurrentUserForScopedLocalSession 1.11 +TestSecurityStatusOrgBoundSettingsFollowOrgManagementRule 1.11 +TestSecurityStatusOrgBoundReportingRespectsAuthorizer 1.10 +TestDiscoveryHandlers_MockModeServesFixturesWithoutService 1.50 +TestDockerAgentReportSizeContractAcceptsOrdinaryAndLargeFleets 0.73 +TestDockerAgentReportSizeContractBoundaries 3.42 +TestHostAgentRemovalLifecycleThroughAuthenticatedRouterAndRestart 0.59 +TestCollectorUninstallTransactionFailsClosedAndRetriesAfterRestart 2.85 +TestHostedTenantAgentInstallCommand_GeneratesOrgBoundTokenAndCommand 4.15 +TestGenerateHostedTenantAgentInstallCommandEnforcesHostedOrgBoundary 1.48 +TestHostedTenantAgentInstallCommandCarriesCommandPolicyIntent 1.98 +TestHostedTenantAgentInstallTokenCannotReportToOtherTenant 0.67 +TestIssue1753SameNameProxmoxAgentsAuthenticateAndStayDistinctEndToEnd 1.73 +TestSyncReleaseDemoFixtureRuntime_DoesNotPoliceMockFixturesInDevBuilds 0.96 +TestMetricsHistoryFallbackSynthesizesUnseededMockWorkloadHistory 1.29 +TestMetricsHistoryFallbackSynthesizesMockHostAndNodeDrawerSeries 1.57 +TestMetricsHistoryFallbackSupplementsPartialMockStoreSeries 1.32 +TestMetricsHistoryFallbackMockDiskSynthesizesSeries 1.30 +TestMetricsHistoryFallbackMockUnifiedStorageSynthesizesSeries 1.79 +TestMetricsHistoryFallbackMockStorageBypassesSparseStoreSeries 1.94 +TestSSOOnlyExplicitAdminReachesEveryAdminGuard 1.11 +TestUnrelatedSSOUserStillRefusedWhenLocalAdminConfigured 1.65 +TestPlatformAdminRouteRefusesUnrelatedSSOUser 0.66 +TestJourneyGeneralAPIBudgetIsolation 1.38 +TestTenantRBACProvider_NoConnectionLeak 1.13 +TestHandleListPointsAcceptsCanonicalPlatformQuery 1.43 +TestHandleListRollupsExposeCanonicalPlatformsPayload 1.46 +TestHandleListRollupsMetaReportsNormalizedPagination 2.13 +TestHandleListPointsMetaReportsNormalizedPagination 2.15 +TestStartPatrolForContext_DoesNotOverwriteOtherTenantPatrolComponents 1.81 +TestStartPatrolForContext_RejectsMismatchedAIServiceOrg 0.98 +TestRouterMockMode_SeedsTrueNASAndVMwareSupplementalResources 1.24 +TestRouterMockMode_SeedsVMwareSupplementalActivity 0.95 +TestRouteExecuteStream_Success 0.74 +TestRouteExecuteStream_MethodNotAllowed 0.60 +TestRouteExecuteStream_WrongScope 0.51 +TestRouteExecuteStream_EmptyPrompt 0.65 +TestRouteExecuteStream_WhitespaceOnlyPrompt 0.58 +TestRouteExecuteStream_InvalidJSON 0.63 +TestRouteExecuteStream_AIDisabled 0.61 +TestRouteExecuteStream_AutofixLicenseRequired 0.59 +TestRouteExecuteStream_RemediationLicenseRequired 0.52 +TestRouteExecuteStream_OversizedBody 0.64 +TestRouteExecuteStream_UseCaseCaseInsensitive 0.68 +TestRouteExecuteStream_SSEHeaders 0.52 +TestRouteExecuteStream_CompleteEventBeforeDone 0.60 +TestRouteExecuteStream_EmptyBody 0.54 +TestRouteExecuteStream_NullPrompt 0.58 +TestRouteExecuteStream_ServiceError 0.55 +TestRouteExecuteStream_InvalidHistoryRole 0.55 +TestRouteExecuteStream_ValidTargetTypes 1.64 +TestRouteExecuteStream_EmptyTargetTypeAllowed 1.18 +TestRouteExecuteStream_WithTargetContext 0.59 +TestRouteExecuteStream_DefaultUseCaseIsChat 0.58 +TestRouteExecuteStream_FindingIDPassedThrough 0.58 +TestRouteExecute_Success 0.58 +TestRouteExecute_MethodNotAllowed 0.59 +TestRouteExecute_NoAuth 0.62 +TestRouteExecute_WrongScope 0.55 +TestRouteExecute_EmptyPrompt 0.58 +TestRouteExecute_InvalidJSON 0.59 +TestRouteExecute_WithConversationHistory 0.54 +TestRouteExecute_WithTargetContext 0.69 +TestRouteExecute_DefaultUseCaseIsChat 0.58 +TestRouteExecute_AutofixLicenseRequired 0.59 +TestRouteExecute_OversizedBody 0.73 +TestRouteExecute_EmptyBody 0.52 +TestRouteExecute_EmptyJSONObject 0.74 +TestRouteExecute_NullPrompt 0.59 +TestRouteExecute_ServiceError 0.58 +TestRouteExecute_InvalidTargetType 0.59 +TestRouteExecute_ValidTargetTypes 1.70 +TestRouteExecute_EmptyTargetTypeAllowed 0.59 +TestRouteExecute_InvalidHistoryRole 0.58 +TestRouteExecute_FindingIDPassedThrough 0.60 +TestRouteExecute_ModelFieldPassedThrough 0.58 +TestRouteListModels_Success 0.54 +TestRouteListModels_MethodNotAllowed 0.52 +TestRouteListModels_WrongScope 0.57 +TestRouteListModels_ProviderError 0.59 +TestRouteListModels_EmptyModelList 0.60 +TestRouteListModels_CachedField 0.60 +TestRouteListModels_MalformedProviderResponse 0.61 +TestRouteListModels_HEADMethod 0.69 +TestRouteTestConnection_Success 0.60 +TestRouteTestConnection_MethodNotAllowed 0.57 +TestRouteTestConnection_NoAuth 0.58 +TestRouteTestConnection_WrongScope 0.58 +TestRouteTestProvider_Success 0.57 +TestRouteTestProvider_UnconfiguredProvider 0.52 +TestRouteTestProvider_MethodNotAllowed 0.60 +TestRouteTestProvider_NoAuth 0.59 +TestRouteTestProvider_MultipleProviders 0.58 +TestRouteTestProvider_WrongScope 0.59 +TestRouteTestProvider_InvalidProviderNames 0.58 +TestRouteTestProvider_PathTraversal 0.67 +TestRouter_HandleState_MockIsolation 3.26 +TestAITestConnectionRouteWithValidScope 0.72 +TestAITestConnectionRouteRejectsWrongScope 0.79 +TestAITestProviderRouteRejectsWrongScope 0.71 +TestPublicEndpointsBypassAuthInAPIMode 0.59 +TestSettingsCapabilitiesMatchRouteEnforcementWithoutRBAC 0.83 +TestSettingsCapabilitiesGrantConfiguredAdminWithoutRBAC 0.90 +TestTrueNASHandlers_HandleList_ReturnsMockConnectionsInMockMode 1.18 +TestFreeBSDOfflineAgentPreflight 1.77 +TestBranchcov0723AmExtractFromZip 8.53 +TestHandleApplyUpdate_BlocksWhenReadinessBlocked 1.42 +TestHandleGetUpdatePlan 1.59 +TestHandleGetUpdatePlan_IncludesUpgradeReadiness 1.29 +TestHandleGetUpdatePlan_InvalidChannel 1.05 +TestHandleGetUpdatePlan_PrepareError 1.38 +TestVMwareHandlers_HandleList_ReturnsMockConnectionsInMockMode 1.28 +TestAlertsEndpoints 22.93 +TestRecoveryRollupsEndpointToleratesMalformedPersistedMetadata 0.53 +TestHealthEndpoint 21.24 +TestVersionEndpointUsesRepoVersion 21.71 +TestAlertAcknowledge_AllowsPrintableAlertIDs 20.98 +TestStateEndpointReturnsMockData 23.11 +TestRecoveryRollupsEndpointReturnsMockData 23.03 +TestProtectedEndpointsRequireAuthentication 20.77 +TestAPIOnlyModeRequiresToken 31.38 +TestServerInfoEndpointReportsDevelopment 27.15 +TestRecoveryPointsEndpointReturnsMockData 31.33 +TestServerInfoEndpointMethodNotAllowed 26.85 +TestHealthEndpointMethodNotAllowed 27.02 +TestHealthEndpointHeadAllowed 26.90 +TestConfigNodesUsesMockTopology 25.70 +TestMockModeToggleEndpoint 36.62 +TestMockModeToggleFromRealDataRebindsPlatformSupplementalProviders 27.70 +TestAuthenticatedEndpointsRequireToken 27.94 +TestAPITokenQueryAndHeaderAuth 33.28 +TestRecoveryEndpointRequiresDirectLoopback 29.26 +TestWebSocketSendsInitialState 27.92 +TestWebsocketPayloadContractShape 25.38 +TestWebsocketPayloadUsesCanonicalStateContract 28.16 +TestSessionCookieAllowsAuthenticatedAccess 29.04 +TestRevokedAPITokenImmediatelyLosesAccess 35.04 +TestLimitedAPITokenCannotCreateBroaderToken 29.25 +TestPublicURLDetectionUsesForwardedHeaders 24.93 +TestPublicURLDetectionRespectsEnvOverride 27.56 diff --git a/.github/scripts/record-internal-api-test-seconds.py b/.github/scripts/record-internal-api-test-seconds.py new file mode 100755 index 0000000000..8006abb945 --- /dev/null +++ b/.github/scripts/record-internal-api-test-seconds.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Print `go test -json` like plain `go test` and record per-test seconds. + +Usage: go test -json ... | record-internal-api-test-seconds.py + +`go test -json` reports every test the way `-v` does, which for internal/api +means thousands of lines per shard. This filter keeps the log as readable as +plain `go test`: package lines (build errors, `ok`, `FAIL`, panics outside a +test) print straight away, and a test's own output is held until the test +ends, printed if it failed and dropped if it passed or was skipped. Output of +a test that never finished (a panic or timeout killed the binary) is printed +at the end, so a crash is never hidden. + +Every finished top-level test is written to as +` `, after a `# package-seconds ` comment +holding the whole test binary's run time, the same shape as +.github/scripts/internal-api-test-seconds.txt, so +refresh-internal-api-test-seconds.py can rebuild the shard weights from CI. + +The exit status is non-zero when any test or package failed, but the caller +must still run with `set -o pipefail` so a `go test` failure that produced no +JSON (for example a vet or build error) fails the step. +""" + +from __future__ import annotations + +import json +import sys +from collections import defaultdict + + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} ", file=sys.stderr) + return 2 + + held: dict[str, list[str]] = defaultdict(list) + running: set[str] = set() + seconds: dict[str, float] = {} + package_seconds = None + failed = False + out = sys.stdout + + for raw in sys.stdin: + try: + event = json.loads(raw) + except ValueError: + event = None + if not isinstance(event, dict): + out.write(raw) + out.flush() + continue + + action = event.get("Action") + test = event.get("Test") or "" + top = test.split("/", 1)[0] + text = event.get("Output") or "" + + if not top: + # Package level: build output, `ok`/`FAIL` summary, panics and + # timeouts reported outside a test. + if text: + out.write(text) + out.flush() + if action == "fail": + failed = True + if action in ("pass", "fail") and isinstance(event.get("Elapsed"), (int, float)): + package_seconds = float(event["Elapsed"]) + continue + + if action == "run" and test == top: + running.add(top) + if text: + held[top].append(text) + if test != top or action not in ("pass", "fail", "skip"): + continue + + running.discard(top) + lines = held.pop(top, []) + if action == "fail": + failed = True + out.write("".join(lines)) + out.flush() + elapsed = event.get("Elapsed") + if isinstance(elapsed, (int, float)): + seconds[top] = float(elapsed) + + for top in sorted(running): + failed = True + out.write(f"--- {top} did not finish; its output follows\n") + out.write("".join(held.get(top, []))) + out.flush() + + with open(sys.argv[1], "w", encoding="utf-8") as handle: + if package_seconds is not None: + handle.write(f"# package-seconds {package_seconds:.2f}\n") + for name, value in seconds.items(): + handle.write(f"{name} {value:.2f}\n") + + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/refresh-internal-api-test-seconds.py b/.github/scripts/refresh-internal-api-test-seconds.py new file mode 100755 index 0000000000..b4f1683f90 --- /dev/null +++ b/.github/scripts/refresh-internal-api-test-seconds.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Rebuild .github/scripts/internal-api-test-seconds.txt from CI timings. + +Every `Backend tests (api-N)` job of Build and Test uploads an +`internal-api-test-seconds-N` artifact written by +record-internal-api-test-seconds.py, holding the elapsed seconds of each +top-level internal/api test it ran under -race on the GitHub runner. This +script downloads those artifacts for one or more runs (or reads files already +on disk), takes the median seconds of every test across the runs, and writes +the weights file that select-internal-api-shard.sh uses to cut the shards. + +Tests at or above --min-seconds are listed by name. Every other test, and any +test added later, weighs the file's DEFAULT_WEIGHT, which is set to the mean +of the unlisted tests so the per-shard totals stay close to what CI measured. + +Usage, from the repository root: + python3 .github/scripts/refresh-internal-api-test-seconds.py --run [--run ...] + python3 .github/scripts/refresh-internal-api-test-seconds.py --dir + +Use runs whose api shards all passed, ideally two or three recent ones, since +a single runner can be 30% faster or slower than the next. Pass --tests with +the output of `go test -list . ./internal/api` to also print the shard cut +the new weights produce. +""" + +from __future__ import annotations + +import argparse +import datetime +import statistics +import subprocess +import sys +import tempfile +from collections import defaultdict +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +WEIGHTS = REPO / ".github" / "scripts" / "internal-api-test-seconds.txt" +SELECTOR = REPO / ".github" / "scripts" / "select-internal-api-shard.sh" +ARTIFACT_PATTERN = "internal-api-test-seconds-*" + + +def download(run_id: str, into: Path, repo: str) -> None: + subprocess.run( + ["gh", "run", "download", run_id, "--repo", repo, "--pattern", ARTIFACT_PATTERN, "--dir", str(into)], + check=True, + ) + + +def read_timings(files: list[Path]) -> tuple[dict[str, list[float]], list[tuple[Path, float | None, float, int]]]: + samples: dict[str, list[float]] = defaultdict(list) + shards = [] + for path in files: + package_seconds = None + total = 0.0 + count = 0 + for line in path.read_text(encoding="utf-8").splitlines(): + fields = line.split() + if len(fields) == 3 and fields[:2] == ["#", "package-seconds"]: + package_seconds = float(fields[2]) + continue + if len(fields) != 2 or fields[0].startswith("#"): + continue + seconds = float(fields[1]) + samples[fields[0]].append(seconds) + total += seconds + count += 1 + shards.append((path, package_seconds, total, count)) + return samples, shards + + +def render(weights: dict[str, float], default: float, sources: list[str], min_seconds: float) -> str: + today = datetime.date.today().isoformat() + header = f"""\ +# Run time in seconds of the slowest internal/api top-level tests under -race +# on a GitHub ubuntu-24.04 runner, the median of the Build and Test runs named +# below. Used only by select-internal-api-shard.sh to choose where the +# Backend tests (api-N) shards cut go test's list. Tests not named here weigh +# DEFAULT_WEIGHT, the mean of the tests under {min_seconds:g}s in the same runs. A +# missing, stale or renamed entry can make the shards uneven but can never +# drop or repeat a test. +# +# Generated {today} by .github/scripts/refresh-internal-api-test-seconds.py +# from {', '.join(sources)}. Every api shard uploads an +# internal-api-test-seconds-N artifact, so rerun that script with recent +# passing runs when one api shard runs well over the others. +DEFAULT_WEIGHT {default:.2f} +""" + body = "".join(f"{name} {seconds:.2f}\n" for name, seconds in weights.items()) + return header + body + + +def report_cut(weights_path: Path, tests_path: Path, shards: int, samples: dict[str, float], default: float) -> None: + tests = [line.strip() for line in tests_path.read_text(encoding="utf-8").splitlines()] + tests = [t for t in tests if t.startswith(("Test", "Fuzz", "Example"))] + stdin = "\n".join(tests) + "\n" + print(f"Predicted cut with {shards} shards (measured seconds, default {default:.2f}s when unmeasured):") + for index in range(shards): + selected = subprocess.run( + ["bash", str(SELECTOR), str(weights_path), str(shards), str(index)], + input=stdin, capture_output=True, text=True, check=True, + ).stdout.split() + seconds = sum(samples.get(name, default) for name in selected) + print(f" api-{index}: {len(selected):5d} tests {seconds:7.1f}s {selected[0]} .. {selected[-1]}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--run", action="append", default=[], help="Build and Test run id to download artifacts from") + parser.add_argument("--dir", action="append", default=[], type=Path, help="directory of downloaded artifacts") + parser.add_argument("--repo", default="rcourtman/Pulse") + parser.add_argument("--min-seconds", type=float, default=0.5) + parser.add_argument("--output", type=Path, default=WEIGHTS) + parser.add_argument("--tests", type=Path, help="go test -list output, to print the resulting shard cut") + parser.add_argument("--shards", type=int, help="shard count for --tests (default: API_SHARD_COUNT from the workflow)") + args = parser.parse_args() + if not args.run and not args.dir: + parser.error("pass at least one --run or --dir") + + with tempfile.TemporaryDirectory() as scratch: + dirs = list(args.dir) + for run_id in args.run: + target = Path(scratch) / run_id + download(run_id, target, args.repo) + dirs.append(target) + files = sorted(path for d in dirs for path in d.rglob("*.txt")) + if not files: + print("no timing files found", file=sys.stderr) + return 1 + by_test, shards = read_timings(files) + + for path, package_seconds, total, count in shards: + wall = f"{package_seconds:7.1f}s" if package_seconds is not None else " ?" + print(f"{path.parent.name}/{path.name}: {count} tests, {total:.1f}s in tests, package {wall}") + + median = {name: statistics.median(values) for name, values in by_test.items()} + listed = {name: seconds for name, seconds in median.items() if seconds >= args.min_seconds} + rest = [seconds for name, seconds in median.items() if name not in listed] + default = max(statistics.fmean(rest) if rest else 0.05, 0.01) + + sources = [f"run {run_id}" for run_id in args.run] + [str(d) for d in args.dir] + args.output.write_text(render(listed, default, sources, args.min_seconds), encoding="utf-8") + print(f"Wrote {len(listed)} tests at {args.min_seconds:g}s or more and DEFAULT_WEIGHT {default:.2f} to {args.output}") + + if args.tests: + shards_count = args.shards + if shards_count is None: + workflow = (REPO / ".github" / "workflows" / "build-and-test.yml").read_text(encoding="utf-8") + shards_count = int(workflow.split("API_SHARD_COUNT: ", 1)[1].split()[0]) + report_cut(args.output, args.tests, shards_count, median, default) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/select-internal-api-shard.sh b/.github/scripts/select-internal-api-shard.sh index 3a0cf0cd4f..512d064383 100755 --- a/.github/scripts/select-internal-api-shard.sh +++ b/.github/scripts/select-internal-api-shard.sh @@ -7,8 +7,10 @@ # Shards are contiguous runs of go test's order, because some internal/api # tests still depend on package state left by the tests just before them. # Cut points are chosen by estimated run time instead of test count. Every -# test weighs its seconds from the weights file, or DEFAULT_WEIGHT when the -# file does not name it (new tests, renamed tests). The smallest per-shard +# test weighs its seconds from the weights file, or the default weight when +# the file does not name it (new tests, renamed tests). The default weight is +# the file's `DEFAULT_WEIGHT ` line, 0.05 when it has none, and a +# DEFAULT_WEIGHT environment variable overrides both. The smallest per-shard # capacity that lets the list be cut into at most contiguous # runs is found by binary search, then the list is filled greedily up to that # capacity. If fewer runs than shards would result, the tail is cut early so @@ -39,7 +41,7 @@ if [ ! -f "$weights" ]; then exit 2 fi -awk -v n="$count" -v want="$index" -v default_weight="${DEFAULT_WEIGHT:-0.05}" ' +awk -v n="$count" -v want="$index" -v env_default="${DEFAULT_WEIGHT:-}" ' function centis(seconds, c) { c = int(seconds * 100 + 0.5) return c < 1 ? 1 : c @@ -64,10 +66,14 @@ awk -v n="$count" -v want="$index" -v default_weight="${DEFAULT_WEIGHT:-0.05}" ' bad = 1 exit 1 } - weight[$1] = $2 + 0 + if ($1 == "DEFAULT_WEIGHT") file_default = $2 + 0 + else weight[$1] = $2 + 0 next } NF { + if (!default_weight) { + default_weight = env_default != "" ? env_default + 0 : (file_default ? file_default : 0.05) + } total++ name[total] = $1 cost[total] = centis(($1 in weight) ? weight[$1] : default_weight) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index cc54b9a6d7..701861305e 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -337,9 +337,9 @@ jobs: fail-fast: false matrix: # Keep API_SHARD_COUNT equal to the number of indexes listed here. - index: [0, 1, 2, 3] + index: [0, 1, 2, 3, 4] env: - API_SHARD_COUNT: 4 + API_SHARD_COUNT: 5 steps: - name: Report documentation-only change @@ -387,7 +387,12 @@ jobs: # small default weight, and the weights only move the cut points, so # coverage never depends on them. Listing with -race builds the same # test binary the run then reuses from cache. Benchmarks are left out - # because plain go test never runs them. + # because plain go test never runs them. The run goes through + # -json so every shard records each test's seconds on the runner + # (uploaded below and fed back into the weights by + # refresh-internal-api-test-seconds.py). The recorder prints only + # package lines and the output of failing or unfinished tests, like + # plain go test, and pipefail keeps a go test failure fatal. run: | set -euo pipefail listing=$(go test -race -list . ./internal/api) @@ -406,14 +411,17 @@ jobs: others=$(printf '%s\n' "$tests" | grep -vxF -f <(printf '%s\n' "$selected") || true) # Name whichever side is shorter. A shard holding most of the tests # is passed as -skip of every other shard's tests, which runs the - # same set in the same order. + # same set in the same order. Never pair -skip with -run. The test + # binary caches only the last compiled pattern, so alternating the + # two recompiles the long skip pattern for every test and subtest, + # which added about seven minutes to such a shard. pattern="^($(printf '%s\n' "$selected" | paste -sd '|' -))\$" filter=(-run "$pattern") if [ -n "$others" ]; then skip="^($(printf '%s\n' "$others" | paste -sd '|' -))\$" if [ "${#skip}" -lt "${#pattern}" ]; then pattern=$skip - filter=(-run . -skip "$pattern") + filter=(-skip "$pattern") fi fi # Linux rejects a single argument over 128 KiB. Fail with a clear @@ -423,7 +431,19 @@ jobs: exit 1 fi echo "internal/api shard ${API_SHARD_INDEX} of ${API_SHARD_COUNT} runs $(printf '%s\n' "$selected" | wc -l | tr -d ' ') of ${total} tests (${selected%%$'\n'*} .. ${selected##*$'\n'})" - go test -race -timeout 50m "${filter[@]}" ./internal/api + timings="$RUNNER_TEMP/internal-api-test-seconds" + mkdir -p "$timings" + go test -race -timeout 50m -json "${filter[@]}" ./internal/api \ + | python3 .github/scripts/record-internal-api-test-seconds.py "$timings/api-${API_SHARD_INDEX}.txt" + + - name: Upload internal/api test timings + if: always() && needs.changes.outputs.code == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: internal-api-test-seconds-${{ matrix.index }} + path: ${{ runner.temp }}/internal-api-test-seconds/ + if-no-files-found: warn + retention-days: 30 # Required check name. Branch protection matches `Backend tests (api)` # exactly, so this job must keep that name and must fail unless every diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 1cf07668de..169e32b9b8 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -26,7 +26,7 @@ needs its own checks. Release publication workflows are unaffected. ### Sharded internal/api backend tests -Build and Test runs the `internal/api` race tests as four `Backend tests +Build and Test runs the `internal/api` race tests as five `Backend tests (api-N)` shards instead of one job, because that one package took about 27 of the 31 minutes of the old `Backend tests (api)` job and set the critical path of every pull request. Each shard lists the package's tests with `go test @@ -41,16 +41,35 @@ contract mock chart test runs about six minutes on the runner and the integration server tail about twenty seconds per test. `.github/scripts/select-internal-api-shard.sh` weighs each test by its seconds in `.github/scripts/internal-api-test-seconds.txt` (the slowest tests only, -with a small default for every unlisted, new or renamed test), finds the -smallest per-shard budget that fits the list into the shard count, and fills -contiguous slices up to it, cutting the tail early if needed so no shard is -empty. The weights only move cut points. Each test is assigned one shard in a -single pass, so a stale weights file can unbalance the shards but never drop -or repeat a test, and the file is refreshed from a `-race -json` run when one -shard drifts well past the others. A shard holding most of the tests passes -`-skip` of every other shard's tests instead of naming its own, which runs the -same tests in the same order and keeps the single argument under the 120,000 -byte ceiling. The required check name +plus a `DEFAULT_WEIGHT` line for every unlisted, new or renamed test), finds +the smallest per-shard budget that fits the list into the shard count, and +fills contiguous slices up to it, cutting the tail early if needed so no shard +is empty. The weights only move cut points. Each test is assigned one shard in +a single pass, so a stale weights file can unbalance the shards but never drop +or repeat a test. The weights must describe the GitHub runner, not a +workstation. A first seeding from one local run scaled by guesswork left the +four shards at 4.5, 8.8, 17.1 and 12.5 minutes. Every shard therefore runs +`go test -json` through `.github/scripts/record-internal-api-test-seconds.py`, +which prints only package lines and the output of failing or unfinished tests +(as plain `go test` does), exits non-zero on any failure behind `pipefail`, +and writes each top-level test's seconds to an `internal-api-test-seconds-N` +artifact that is uploaded even when the shard fails. +`.github/scripts/refresh-internal-api-test-seconds.py --run ` downloads +those artifacts with `gh run download`, takes each test's median across the +given runs, and rewrites the weights file with `DEFAULT_WEIGHT` set to the mean +of the unlisted tests. Until such runs exist, the weights are a local +`-race -json` run with each region of the list scaled to the seconds the +runner reported for it across the count-based and weighted runs. Five shards +put the six minute contract mock chart test with only its next few +neighbours, and the predicted slowest shard is about 8.5 minutes against about +10 with four, which keeps a 20% slower runner under the 11 minutes of the +Frontend and rest checks. A shard holding most of the +tests passes `-skip` of every other shard's tests instead of naming its own, +which runs the same tests in the same order and keeps the single argument +under the 120,000 byte ceiling. It passes `-skip` alone. The test binary +caches only its last compiled pattern, so `-run .` next to `-skip` recompiled +the long skip pattern for every test and subtest and roughly doubled that +shard. The required check name `Backend tests (api)` belongs to a verdict job that passes only when every shard succeeded, and `Backend tests (rest-0)` and `Backend tests (rest-1)` keep their names and package split. All shards still expand for documentation-only diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index fc356b735b..8df54c6569 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4512,7 +4512,9 @@ ".github/ISSUE_TEMPLATE/v6_rc_feedback.yml", ".github/scripts/check-demo-reachability.sh", ".github/scripts/internal-api-test-seconds.txt", + ".github/scripts/record-internal-api-test-seconds.py", ".github/scripts/recover-demo-runtime.sh", + ".github/scripts/refresh-internal-api-test-seconds.py", ".github/scripts/resolve-demo-runtime-profile.sh", ".github/scripts/select-internal-api-shard.sh", ".github/scripts/setup-demo-ssh.sh", @@ -5038,6 +5040,8 @@ "match_prefixes": [], "match_files": [ ".github/scripts/internal-api-test-seconds.txt", + ".github/scripts/record-internal-api-test-seconds.py", + ".github/scripts/refresh-internal-api-test-seconds.py", ".github/scripts/select-internal-api-shard.sh", ".github/workflows/build-and-test.yml", "frontend-modern/package-lock.json", diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index bb72037e38..8f89371cc1 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -3848,15 +3848,28 @@ func TestBackendAPIShardsKeepRequiredCheckExhaustive(t *testing.T) { // A shard holding most tests is named by skipping every other // shard's tests, which keeps its argument under the exec limit. `others=$(printf '%s\n' "$tests" | grep -vxF -f <(printf '%s\n' "$selected") || true)`, - `filter=(-run . -skip "$pattern")`, + `filter=(-skip "$pattern")`, `if [ "${#pattern}" -gt 120000 ]; then`, - `go test -race -timeout 50m "${filter[@]}" ./internal/api`, + // Every shard records per-test seconds on the runner through -json + // so the weights can be refreshed from CI, while pipefail keeps a + // go test failure fatal behind the recorder. + "set -euo pipefail", + `go test -race -timeout 50m -json "${filter[@]}" ./internal/api \ + | python3 .github/scripts/record-internal-api-test-seconds.py "$timings/api-${API_SHARD_INDEX}.txt"`, + "if: always() && needs.changes.outputs.code == 'true'", + "name: internal-api-test-seconds-${{ matrix.index }}", + "path: ${{ runner.temp }}/internal-api-test-seconds/", "PULSE_DATA_DIR: /tmp/pulse-test-data", } { if !strings.Contains(shards, required) { t.Fatalf("internal/api shard job missing %q", required) } } + // The test binary caches one compiled pattern, so -run next to -skip + // recompiles the long skip pattern for every test and subtest. + if strings.Contains(shards, "-run . -skip") { + t.Fatal("internal/api shards must pass -skip alone; pairing it with -run recompiles the skip pattern per test") + } if strings.Contains(shards, "sort") { t.Fatal("internal/api shards must keep go test's run order; sorting splits order-coupled tests") } @@ -3881,6 +3894,67 @@ func TestBackendAPIShardsKeepRequiredCheckExhaustive(t *testing.T) { } assertInternalAPIShardSelectionExhaustive(t, want) + assertInternalAPITimingRecorderKeepsFailuresVisible(t) +} + +// assertInternalAPITimingRecorderKeepsFailuresVisible feeds the -json +// recorder a passing, a failing and an unfinished test. Passing output must +// stay hidden like plain go test, failing and unfinished output must print, +// any failure must exit non-zero, and every finished top-level test must be +// written with its seconds. +func assertInternalAPITimingRecorderKeepsFailuresVisible(t *testing.T) { + t.Helper() + recorder := repoFile(".github", "scripts", "record-internal-api-test-seconds.py") + record := func(events string) (string, string, error) { + out := filepath.Join(t.TempDir(), "seconds.txt") + cmd := exec.Command("python3", recorder, out) + cmd.Stdin = strings.NewReader(events) + printed, err := cmd.Output() + written, readErr := os.ReadFile(out) + if readErr != nil { + t.Fatalf("recorder wrote no seconds file: %v", readErr) + } + return string(printed), string(written), err + } + const pkg = `"Package":"example/internal/api"` + passing := strings.Join([]string{ + `{"Action":"run",` + pkg + `,"Test":"TestQuiet"}`, + `{"Action":"output",` + pkg + `,"Test":"TestQuiet","Output":"quiet log line\n"}`, + `{"Action":"pass",` + pkg + `,"Test":"TestQuiet","Elapsed":1.25}`, + `{"Action":"output",` + pkg + `,"Output":"ok \texample/internal/api\t2.000s\n"}`, + `{"Action":"pass",` + pkg + `,"Elapsed":2}`, + }, "\n") + "\n" + printed, written, err := record(passing) + if err != nil { + t.Fatalf("recorder must pass a passing run: %v", err) + } + if strings.Contains(printed, "quiet log line") || !strings.Contains(printed, "ok \texample/internal/api") { + t.Fatalf("recorder must print package lines and hide passing test output, printed %q", printed) + } + if written != "# package-seconds 2.00\nTestQuiet 1.25\n" { + t.Fatalf("recorder seconds file = %q", written) + } + + failing := strings.Join([]string{ + `{"Action":"run",` + pkg + `,"Test":"TestBroken"}`, + `{"Action":"output",` + pkg + `,"Test":"TestBroken/case","Output":"broken detail\n"}`, + `{"Action":"fail",` + pkg + `,"Test":"TestBroken/case","Elapsed":0.5}`, + `{"Action":"fail",` + pkg + `,"Test":"TestBroken","Elapsed":0.75}`, + `{"Action":"run",` + pkg + `,"Test":"TestHung"}`, + `{"Action":"output",` + pkg + `,"Test":"TestHung","Output":"panic: test timed out\n"}`, + }, "\n") + "\n" + printed, written, err = record(failing) + if err == nil { + t.Fatal("recorder must exit non-zero when a test fails or never finishes") + } + for _, want := range []string{"broken detail", "TestHung did not finish", "panic: test timed out"} { + if !strings.Contains(printed, want) { + t.Fatalf("recorder must print %q for failing or unfinished tests, printed %q", want, printed) + } + } + if written != "TestBroken 0.75\n" { + t.Fatalf("recorder must record only finished top-level tests, wrote %q", written) + } } // assertInternalAPIShardSelectionExhaustive runs the shard selector the way @@ -3904,12 +3978,15 @@ func assertInternalAPIShardSelectionExhaustive(t *testing.T, workflowShards int) if len(fields) == 0 || strings.HasPrefix(fields[0], "#") { continue } - if len(fields) != 2 || !strings.HasPrefix(fields[0], "Test") { - t.Fatalf("internal/api weights line must be ` `, got %q", line) + if len(fields) != 2 || !(strings.HasPrefix(fields[0], "Test") || fields[0] == "DEFAULT_WEIGHT") { + t.Fatalf("internal/api weights line must be ` ` or `DEFAULT_WEIGHT `, got %q", line) } if seconds, err := strconv.ParseFloat(fields[1], 64); err != nil || seconds <= 0 { t.Fatalf("internal/api weight for %s must be positive seconds, got %q", fields[0], fields[1]) } + if fields[0] == "DEFAULT_WEIGHT" { + continue + } weighted = append(weighted, fields[0]) } if len(weighted) == 0 { @@ -3928,7 +4005,7 @@ func assertInternalAPIShardSelectionExhaustive(t *testing.T, workflowShards int) t.Fatal(err) } skewedWeights := filepath.Join(t.TempDir(), "skewed.txt") - skewed := tests[len(tests)/3] + " 900\n" + tests[len(tests)-1] + " 450\n" + skewed := "DEFAULT_WEIGHT 7.5\n" + tests[len(tests)/3] + " 900\n" + tests[len(tests)-1] + " 450\n" if err := os.WriteFile(skewedWeights, []byte(skewed), 0o644); err != nil { t.Fatal(err) } From eafd48d197c5ee14481892e7c6c0df4779297648 Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Tue, 29 Sep 2026 16:42:35 +0100 Subject: [PATCH 5/5] Give RC qualification a local host-interface canary The immutable RC systemd lab failed before installed acceptance because its --network none container had no non-loopback IPv4 address to prove the helper's private network cannot reach. Add a veth canary wholly inside that namespace and assert no default route before the lab runs. Also check out the repository in the demo resolver before it calls scripts/write_github_output.py, so verification-only dry runs reach their demo checks. Split out of #2323 so the v6.4.5 promotion is not held behind the unrelated action-review UI work in that PR. --- .../qualify-secure-runtime-release.yml | 16 +++++++- .github/workflows/update-demo-server.yml | 14 +++---- .../subsystems/deployment-installability.md | 12 ++++++ .../installtests/build_release_assets_test.go | 38 ++++++++++++++++++- 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/.github/workflows/qualify-secure-runtime-release.yml b/.github/workflows/qualify-secure-runtime-release.yml index d0fcc1a2dd..348c337f31 100644 --- a/.github/workflows/qualify-secure-runtime-release.yml +++ b/.github/workflows/qualify-secure-runtime-release.yml @@ -224,7 +224,7 @@ jobs: COPY --from=dind /usr/local/bin/ /usr/local/bin/ COPY pulse-qualification-docker.service /etc/systemd/system/pulse-qualification-docker.service RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - ca-certificates curl dbus systemd systemd-sysv util-linux && \ + ca-certificates curl dbus systemd systemd-sysv util-linux iproute2 && \ apt-get clean && \ systemctl mask dev-hugepages.mount sys-fs-fuse-connections.mount systemd-remount-fs.service docker.service docker.socket && \ systemctl enable pulse-qualification-docker.service @@ -269,7 +269,19 @@ jobs: sleep 2 done docker exec "${container}" sh -c \ - 'for command in curl docker dockerd id nsenter runuser systemctl; do command -v "$command" >/dev/null || exit 1; done' + 'for command in curl docker dockerd id ip nsenter runuser systemctl; do command -v "$command" >/dev/null || exit 1; done' + # The immutable RC test needs a reachable non-loopback host-interface + # canary to prove that the helper's private network cannot reach it. + # Both veth ends stay inside this --network none namespace; neither + # endpoint is attached to the runner or an egress bridge. + docker exec "${container}" sh -ceu ' + ip link add pulse-can0 type veth peer name pulse-can1 + ip address add 192.0.2.1/32 dev pulse-can0 + ip link set pulse-can1 up + ip link set pulse-can0 up + test -z "$(ip -4 route show default)" + ip -4 address show dev pulse-can0 | grep -F "192.0.2.1/32" + ' docker exec "${container}" systemctl start pulse-qualification-docker.service for attempt in $(seq 1 60); do if docker exec "${container}" docker info >/dev/null 2>&1; then diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index c457385175..996f7bda56 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -74,6 +74,13 @@ jobs: mock_update_interval: ${{ steps.runtime_profile.outputs.mock_update_interval }} steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + fetch-tags: true + - name: Resolve target tag and demo environment id: target env: @@ -137,13 +144,6 @@ jobs: echo "environment_name=$ENVIRONMENT_NAME" >> "$GITHUB_OUTPUT" echo "Resolved demo deployment: tag=${TAG}, target=${TARGET}, environment=${ENVIRONMENT_NAME}" - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - fetch-depth: 0 - fetch-tags: true - - name: Validate governed release line for selected tag env: WORKFLOW_OUTPUT_1: ${{ steps.target.outputs.tag }} diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 169e32b9b8..2520ea971c 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1296,6 +1296,14 @@ artifact-selection behaviour. privileged systemd container must mount that exact snapshot read-only and must never execute directly from the download directory. The post-run attester must consume the same snapshot paths. + The outer systemd qualification container must retain `--network none`: + it may create a non-loopback host-interface canary only as a veth pair + wholly inside that container's network namespace, with no runner bridge, + egress attachment, or default IPv4 route. The immutable RC lab uses this + reachable local canary to distinguish host-network access from the typed + helper's private network isolation. Canary setup and the no-default-route + check must fail qualification before that lab if the boundary is absent; + they must not skip the lab or change the release packet under test. PVE jobs must consume their runner users' persistent local Go and npm caches directly; disposable-runner Actions cache restore/save phases must remain disabled because archiving those same caches adds network work after the @@ -3831,6 +3839,10 @@ bypass the preflight. That dry run must call `update-demo-server.yml` in verification-only mode against the latest stable release. It must prove Tailscale, SSH host identity, runtime version, frontend parity, public health, and browser smoke without changing the host. +The reusable demo workflow's resolver must check out the repository before it +calls `scripts/write_github_output.py` to emit the selected tag and target. +This checkout is credential-free; a verification-only dry run must not fail +before its demo checks merely because the output helper is absent. That same release-validation boundary also owns draft-versus-published asset state. Every normal `.github/workflows/create-release.yml` cut validates the uploaded packet while the release is still a draft and must pass `draft=true` diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 8f89371cc1..96e889fd40 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2392,6 +2392,29 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { } } +func TestUpdateDemoResolverChecksOutOutputHelperBeforeUse(t *testing.T) { + workflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "update-demo-server.yml")) + if err != nil { + t.Fatalf("read update-demo-server workflow: %v", err) + } + workflow := string(workflowBytes) + resolveStart := strings.Index(workflow, "\n resolve:\n") + updateStart := strings.Index(workflow, "\n update-demo:\n") + if resolveStart < 0 || updateStart <= resolveStart { + t.Fatal("update-demo-server workflow must retain separate resolve and update-demo jobs") + } + resolve := workflow[resolveStart:updateStart] + checkout := strings.Index(resolve, "- name: Checkout repository") + target := strings.Index(resolve, "- name: Resolve target tag and demo environment") + helper := strings.Index(resolve, "python3 scripts/write_github_output.py tag \"$TAG\"") + if checkout < 0 || target <= checkout || helper <= target { + t.Fatal("demo resolver must check out its source before using scripts/write_github_output.py") + } + if !strings.Contains(resolve[checkout:target], "persist-credentials: false") { + t.Fatal("demo resolver checkout must not persist GitHub credentials") + } +} + func TestDemoMutationAndRecoverySharePhysicalTargetLock(t *testing.T) { updateBytes, err := os.ReadFile(repoFile(".github", "workflows", "update-demo-server.yml")) if err != nil { @@ -2824,12 +2847,15 @@ func TestSecureRuntimeQualificationPacketIsHostedAndReleaseBound(t *testing.T) { `git remote set-url origin https://github.com/rcourtman/Pulse.git`, "ca-certificates curl dbus systemd systemd-sysv util-linux", `docker:27.5.1-dind@sha256:f649ef046008ca7f926a2571c32b0ac22e5c59eb61b959617f9acc2a4c638cf5`, - `for command in curl docker dockerd id nsenter runuser systemctl`, + `for command in curl docker dockerd id ip nsenter runuser systemctl`, `--host=unix:///var/run/docker.sock`, `--storage-driver=vfs`, `--bridge=none`, `--iptables=false`, `--network none`, + `ip link add pulse-can0 type veth peer name pulse-can1`, + `ip address add 192.0.2.1/32 dev pulse-can0`, + `test -z "$(ip -4 route show default)"`, `pulse-secure-runtime-fixture:v7`, "--verify-release-packet-only", "--verified-packet-dir", @@ -2847,6 +2873,16 @@ func TestSecureRuntimeQualificationPacketIsHostedAndReleaseBound(t *testing.T) { t.Fatalf("post-publication secure-runtime qualification missing %q", required) } } + canaryIndex := strings.Index(qualificationWorkflow, `ip link add pulse-can0 type veth peer name pulse-can1`) + labIndex := strings.Index(qualificationWorkflow, `--env PULSE_SECURE_RUNTIME_SYSTEMD_LAB=1`) + if canaryIndex <= privilegedExecutionIndex || labIndex <= canaryIndex { + t.Fatal("the isolated host-interface canary must be configured before running the immutable RC lab") + } + if !strings.Contains(qualificationWorkflow, `--privileged \ + --network none \ + --cgroupns=host`) { + t.Fatal("the outer systemd lab must retain its no-network namespace") + } for _, forbidden := range []string{ `/var/run/docker.sock:/var/run/docker.sock`, `--host=tcp://`,