diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 169ac259e..8c1616a52 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -146,13 +146,17 @@ reverse. Server update planning is part of the same lifecycle contract. The System Updates plan must surface a structured upgrade-readiness verdict before an -operator installs a v6 update: server update path, registered agent continuity, -and agent reporting token scope must be derived from the existing updater, -connections ledger, and API token state rather than from docs-only guidance or -a parallel migration registry. v5 or legacy agents that are still reporting -must be treated as compatible with the v6 reporting boundary, while stale -agents, missing reported versions, missing agent reporting scope, or expired -agent tokens must be called out before the update starts. +operator installs a v6 update, and the backend apply route must enforce a +`blocked` verdict before starting the updater: server update path, registered +agent continuity, and agent reporting token scope must be derived from the +existing updater, connections ledger, and API token state rather than from +docs-only guidance or a parallel migration registry. v5 or legacy agents that +are still reporting must be treated as compatible with the v6 reporting +boundary, while stale agents, missing reported versions, missing agent +reporting scope, or expired agent tokens must be called out before the update +starts. Root `install.sh` v5-to-v6 upgrades must also inspect local token +metadata before binary replacement and warn when the shell path cannot prove +agent-token continuity. Agent lifecycle and fleet-operation surfaces may consume `POST /api/actions/plan` for resource capability planning, but the action plan diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index b034f74f3..ba809c742 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -189,7 +189,9 @@ platform page needs source-native backup columns. 9. `frontend-modern/src/api/updates.ts` shared with `deployment-installability`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary. It must preserve `/api/updates/plan.readiness` payloads as backend-owned API state so settings UI can render `ready`, `attention`, and `blocked` - update checks without rebuilding upgrade state locally. + update checks without rebuilding upgrade state locally. The frontend may + disable blocked installs, but the backend apply route remains authoritative + and must reject a recomputed `blocked` readiness verdict. 10. `frontend-modern/src/components/Settings/APITokenManager.tsx` shared with `security-privacy`: the API token settings surface is both a security/privacy control surface and a canonical API payload contract boundary. The API token inventory table may own credential and usage cells, but it must inherit embedded table framing from `frontend-primitives` @@ -428,7 +430,10 @@ platform page needs source-native backup columns. Update-plan responses own the structured readiness verdict for server updater capability, rollback support, agent continuity, and agent reporting token scope. That verdict is part of the update-plan API contract, not a - settings-only migration registry. + settings-only migration registry. `POST /api/updates/apply` must derive + the requested target version through the shared update-target validation + path, recompute readiness from live backend state, and reject `blocked` + verdicts before update execution starts. The platform-connections API contract also owns inactive monitored-system candidate semantics end to end. `enabled=false` on TrueNAS or VMware preview, test, add, and update payloads must serialize through the shared ledger client @@ -3666,7 +3671,9 @@ The same update-plan contract now carries an optional `readiness` verdict. Backend handlers own the `ready` / `attention` / `blocked` status vocabulary and per-check payload shape, while frontend clients must preserve that payload unchanged so settings surfaces can disable automatic install on blocked checks -without inventing a parallel migration state model. +without inventing a parallel migration state model. The UI disablement is only +presentation: backend apply handlers must still enforce `blocked` readiness +server-side. Those same install-command payloads now also carry a non-TLS continuity contract: when Pulse returns a plain `http://` base URL for a generated agent install command, the command must include `--insecure` so the installed agent diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 84c33897f..b1b1a2e47 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -174,13 +174,19 @@ server-side update execution surfaces. must preserve server-derived `owner_user_id` lineage on bootstrap tokens and enrollment runtime tokens while keeping deploy binding metadata limited to deploy facts such as cluster, job, target, source agent, and expected node. -4. Add or change server update transport through `internal/api/updates.go` and `frontend-modern/src/api/updates.ts` +4. Add or change server update transport through `internal/api/updates.go`, `internal/updates/`, and `frontend-modern/src/api/updates.ts` Server update planning must attach the canonical upgrade-readiness verdict - to `/api/updates/plan` responses before an operator starts a v6 update. + to `/api/updates/plan` responses before an operator starts a v6 update, and + `POST /api/updates/apply` must recompute the same verdict and reject + `blocked` updates server-side rather than trusting the settings UI alone. The verdict belongs to the update plan, not to a separate migration wizard: it must combine updater capability, rollback availability, registered agent continuity, and agent reporting token scope so v5-to-v6 continuity problems - are visible before relaunch. + are visible before relaunch. The root `install.sh` non-UI path must run a + conservative v5-to-v6 local preflight before replacing the binary, blocking + unreadable token state and warning about missing, expired, or soon-expiring + agent reporting scopes without pretending shell-only inspection can prove + live registered-agent continuity. 5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, deterministic dev auth seeding, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `scripts/lib/hot-dev-runtime.sh`, `scripts/lib/hot-dev-auth.sh`, `scripts/dev-deploy-agent.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md` First-run browser helpers are part of that dev-runtime proof boundary. They must preserve the setup-created API token in the shared runtime state, prefer diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 41c217d89..f276c37c9 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -211,11 +211,12 @@ recovery scope, or a storage/recovery-owned secret source. 1. Add or change recovery-point persistence, rollups, or series derivation through `internal/recovery/` 4. Route transport changes for storage and recovery endpoints through `internal/api/` and the owning `api-contracts` proof routes - Update-plan readiness payloads are adjacent shared API context only. Storage - and recovery surfaces may observe the resulting update state if a future - settings flow links to recovery preparation, but they must not reinterpret - agent-token or server-update readiness checks as backup freshness, restore - capability, or storage-provider health. + Update-plan readiness payloads and apply-route readiness enforcement are + adjacent shared API context only. Storage and recovery surfaces may observe + the resulting update state if a future settings flow links to recovery + preparation, but they must not reinterpret agent-token or server-update + readiness checks as backup freshness, restore capability, or storage-provider + health. Shared API-token transport helpers may be consumed by storage/recovery- adjacent flows, but `owner_user_id` remains server-authored token identity metadata; storage/recovery extensions must not pass metadata that authors diff --git a/install.sh b/install.sh index 8010e1182..6daeaebcd 100755 --- a/install.sh +++ b/install.sh @@ -97,6 +97,8 @@ ENABLE_AUTO_UPDATES=false AUTO_UPDATE_CHOICE_EXPLICIT=false FORCE_VERSION="" FORCE_CHANNEL="" +SKIP_UPGRADE_PREFLIGHT="${PULSE_SKIP_UPGRADE_PREFLIGHT:-false}" +UPGRADE_PREFLIGHT_RAN=false ARCHIVE_OVERRIDE="${PULSE_ARCHIVE_PATH:-}" SOURCE_BRANCH="main" CURRENT_INSTALL_CTID="" @@ -2470,6 +2472,161 @@ compare_versions() { return 0 # versions are equal } +version_major() { + local version="${1:-}" + version="${version#v}" + version="${version%%-*}" + + local major="${version%%.*}" + if [[ ! "$major" =~ ^[0-9]+$ ]]; then + return 1 + fi + + printf '%s\n' "$major" +} + +is_pre_v6_to_v6_upgrade() { + local current_version="${1:-}" + local target_version="${2:-}" + local current_major="" + local target_major="" + + current_major=$(version_major "$current_version" 2>/dev/null || true) + target_major=$(version_major "$target_version" 2>/dev/null || true) + + if [[ -z "$current_major" || -z "$target_major" ]]; then + return 1 + fi + + [[ "$current_major" -lt 6 && "$target_major" -ge 6 ]] +} + +inspect_api_tokens_for_upgrade() { + local token_file="$1" + + if ! command -v python3 >/dev/null 2>&1; then + return 1 + fi + + python3 - "$token_file" <<'PY' +import datetime +import json +import sys + +path = sys.argv[1] +try: + with open(path, "r", encoding="utf-8") as handle: + tokens = json.load(handle) +except Exception: + print("invalid\t0\t0\t0\t0") + sys.exit(0) + +if not isinstance(tokens, list): + print("invalid\t0\t0\t0\t0") + sys.exit(0) + +now = datetime.datetime.now(datetime.timezone.utc) +agent_scoped = 0 +expired = 0 +expiring = 0 +for token in tokens: + if not isinstance(token, dict): + continue + scopes = token.get("scopes") + if not scopes: + scopes = ["*"] + if not isinstance(scopes, list): + scopes = [str(scopes)] + normalized = {str(scope).strip() for scope in scopes} + if not ({"*", "agent:report", "host-agent:report"} & normalized): + continue + agent_scoped += 1 + expires_at = token.get("expiresAt") or token.get("expires_at") + if not expires_at: + continue + try: + raw = str(expires_at).strip() + parsed = datetime.datetime.fromisoformat(raw.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=datetime.timezone.utc) + except Exception: + continue + if parsed <= now: + expired += 1 + elif parsed - now <= datetime.timedelta(days=14): + expiring += 1 + +print(f"ok\t{len(tokens)}\t{agent_scoped}\t{expired}\t{expiring}") +PY +} + +run_upgrade_readiness_preflight() { + local current_version="${1:-}" + local target_version="${2:-}" + local token_file="$CONFIG_DIR/api_tokens.json" + + if ! is_pre_v6_to_v6_upgrade "$current_version" "$target_version"; then + return 0 + fi + + if [[ "$UPGRADE_PREFLIGHT_RAN" == "true" ]]; then + return 0 + fi + UPGRADE_PREFLIGHT_RAN=true + + if [[ "$SKIP_UPGRADE_PREFLIGHT" == "true" ]]; then + print_warn "Skipping Pulse v5 to v6 upgrade preflight because PULSE_SKIP_UPGRADE_PREFLIGHT=true or --skip-upgrade-preflight was set" + return 0 + fi + + echo + print_info "Running Pulse v5 to v6 upgrade preflight..." + + if [[ -e "$token_file" && ! -r "$token_file" ]]; then + print_error "Cannot read API token inventory at $token_file" + print_info "Fix file permissions before upgrading so Pulse can preserve agent credentials." + return 1 + fi + + if [[ ! -f "$token_file" ]]; then + print_warn "No API token inventory found at $token_file" + print_info "If this instance has v5 host agents, refresh or reinstall their agent token after the server starts on v6." + return 0 + fi + + local report="" + local status="" + local total_tokens=0 + local agent_tokens=0 + local expired_tokens=0 + local expiring_tokens=0 + + report=$(inspect_api_tokens_for_upgrade "$token_file" 2>/dev/null || true) + IFS=$'\t' read -r status total_tokens agent_tokens expired_tokens expiring_tokens <<<"$report" + + if [[ "$status" == "ok" ]]; then + if [[ "$agent_tokens" -gt 0 && "$expired_tokens" -lt "$agent_tokens" ]]; then + print_success "Agent reporting token scope is present for v6 compatibility" + if [[ "$expired_tokens" -gt 0 || "$expiring_tokens" -gt 0 ]]; then + print_warn "$expired_tokens agent reporting token(s) are expired and $expiring_tokens expire within 14 days" + fi + elif [[ "$agent_tokens" -gt 0 ]]; then + print_warn "All agent reporting tokens in api_tokens.json are expired" + print_info "Existing agents may need a refreshed install token after the upgrade." + else + print_warn "No agent reporting token scope was found in api_tokens.json" + print_info "Existing v5 host agents need a token with agent:report or host-agent:report to reconnect through v6 compatibility routes." + fi + elif grep -Eq '"(agent:report|host-agent:report|\\*)"' "$token_file" 2>/dev/null; then + print_warn "API token inventory could not be parsed, but an agent reporting scope string is present" + else + print_warn "API token inventory could not be parsed and no agent reporting scope string was found" + fi + + print_info "The in-app updater performs the live registered-agent readiness check before applying v6 updates." + return 0 +} + check_existing_installation() { CURRENT_VERSION="" # Make it global so we can use it later @@ -3052,6 +3209,10 @@ download_pulse() { expected_release="$LATEST_RELEASE" fi + if ! run_upgrade_readiness_preflight "$CURRENT_VERSION" "$expected_release"; then + exit 1 + fi + # Detect and stop existing service after the archive is available but before replacing the binary. EXISTING_SERVICE=$(detect_service_name) if timeout 5 systemctl is-active --quiet "$EXISTING_SERVICE" 2>/dev/null; then @@ -3503,7 +3664,7 @@ auto_selector_allowed=true if [[ \${#helper_args[@]} -gt 0 ]]; then for helper_arg in "\${helper_args[@]}"; do case "\$helper_arg" in - -h|--help|--uninstall|--version|--rc|--pre|--stable|--source|--from-source|--branch|--archive|--archive=*) + -h|--help|--uninstall|--version|--rc|--pre|--stable|--source|--from-source|--branch|--archive|--archive=*|--skip-upgrade-preflight) auto_selector_allowed=false break ;; @@ -4100,6 +4261,10 @@ main() { # Detect the actual service name before trying to stop it SERVICE_NAME=$(detect_service_name) + + if ! run_upgrade_readiness_preflight "$CURRENT_VERSION" "$LATEST_RELEASE"; then + exit 1 + fi backup_existing systemctl stop $SERVICE_NAME || true @@ -4296,6 +4461,10 @@ main() { fi fi fi + + if ! run_upgrade_readiness_preflight "$CURRENT_VERSION" "$LATEST_RELEASE"; then + exit 1 + fi backup_existing systemctl stop $SERVICE_NAME || true @@ -4646,6 +4815,10 @@ while [[ $# -gt 0 ]]; do AUTO_UPDATE_CHOICE_EXPLICIT=true shift ;; + --skip-upgrade-preflight) + SKIP_UPGRADE_PREFLIGHT=true + shift + ;; --source|--from-source|--branch) BUILD_FROM_SOURCE=true # Optional: specify branch @@ -4669,6 +4842,7 @@ while [[ $# -gt 0 ]]; do echo " --source [BRANCH] Build and install from source (default: main)" echo " --enable-auto-updates Enable automatic stable updates (via systemd timer)" echo " --disable-auto-updates Explicitly keep automatic updates disabled" + echo " --skip-upgrade-preflight Skip v5 to v6 compatibility preflight" echo "" echo "Management options:" echo " --reset Reset Pulse to fresh configuration" diff --git a/internal/api/updates.go b/internal/api/updates.go index 45735ec30..71401fe2a 100644 --- a/internal/api/updates.go +++ b/internal/api/updates.go @@ -152,6 +152,37 @@ func (h *UpdateHandlers) HandleApplyUpdate(w http.ResponseWriter, r *http.Reques http.Error(w, err.Error(), http.StatusBadRequest) return } + + if h.hasUpdateReadinessSources() { + targetVersion, err := updates.ValidateApplyTargetVersion(channel, req.DownloadURL) + if err != nil { + statusCode, msg := classifyApplyUpdateStartError(err) + log.Warn().Err(err).Str("download_url", req.DownloadURL).Str("channel", channel).Msg("Update request rejected") + http.Error(w, msg, statusCode) + return + } + + plan, statusCode, msg, err := h.prepareUpdatePlan(r.Context(), targetVersion, channel) + if err != nil { + if statusCode >= http.StatusInternalServerError { + log.Error().Err(err).Str("version", targetVersion).Str("channel", channel).Msg("Failed to prepare update readiness before apply") + } else { + log.Warn().Err(err).Str("version", targetVersion).Str("channel", channel).Msg("Update readiness request rejected") + } + http.Error(w, msg, statusCode) + return + } + if plan.Readiness != nil && plan.Readiness.Status == updateReadinessBlocked { + log.Warn(). + Str("version", targetVersion). + Str("channel", channel). + Str("summary", plan.Readiness.Summary). + Msg("Update request blocked by readiness checks") + http.Error(w, plan.Readiness.Summary, http.StatusConflict) + return + } + } + applyReq := updates.ApplyUpdateRequest{ DownloadURL: req.DownloadURL, Channel: channel, @@ -370,13 +401,6 @@ func (h *UpdateHandlers) HandleGetUpdatePlan(w http.ResponseWriter, r *http.Requ return } - // Get current version info to determine deployment type - versionInfo, err := updates.GetCurrentVersion() - if err != nil { - http.Error(w, "Failed to get version info", http.StatusInternalServerError) - return - } - // Get version from query version := r.URL.Query().Get("version") if version == "" { @@ -389,36 +413,45 @@ func (h *UpdateHandlers) HandleGetUpdatePlan(w http.ResponseWriter, r *http.Requ return } - // Get updater for deployment type. Some valid deployments intentionally do - // not support unattended updates; return a manual plan instead of surfacing - // a transport error to the UI. - updater, err := h.registry.Get(versionInfo.DeploymentType) + plan, statusCode, msg, err := h.prepareUpdatePlan(r.Context(), version, channel) if err != nil { - if plan, ok := fallbackManualUpdatePlan(versionInfo.DeploymentType, version); ok { - w.Header().Set("Content-Type", "application/json") - normalizedPlan := h.attachUpdateReadiness(r.Context(), version, plan.NormalizeCollections()) - json.NewEncoder(w).Encode(normalizedPlan) - return + if statusCode >= http.StatusInternalServerError { + log.Error().Err(err).Str("version", version).Str("channel", channel).Msg("Failed to prepare update plan") } - - http.Error(w, "No updater for deployment type", http.StatusNotFound) - return - } - - // Prepare update plan - plan, err := updater.PrepareUpdate(r.Context(), updates.UpdateRequest{ - Version: version, - Channel: channel, - }) - if err != nil { - log.Error().Err(err).Msg("Failed to prepare update plan") - http.Error(w, "Failed to prepare update plan", http.StatusInternalServerError) + http.Error(w, msg, statusCode) return } w.Header().Set("Content-Type", "application/json") - normalizedPlan := h.attachUpdateReadiness(r.Context(), version, plan.NormalizeCollections()) - json.NewEncoder(w).Encode(normalizedPlan) + json.NewEncoder(w).Encode(plan) +} + +func (h *UpdateHandlers) prepareUpdatePlan(ctx context.Context, version string, channel string) (updates.UpdatePlan, int, string, error) { + versionInfo, err := updates.GetCurrentVersion() + if err != nil { + return updates.UpdatePlan{}, http.StatusInternalServerError, "Failed to get version info", err + } + + updater, err := h.registry.Get(versionInfo.DeploymentType) + if err != nil { + if plan, ok := fallbackManualUpdatePlan(versionInfo.DeploymentType, version); ok { + normalizedPlan := h.attachUpdateReadiness(ctx, version, plan.NormalizeCollections()) + return normalizedPlan, 0, "", nil + } + + return updates.UpdatePlan{}, http.StatusNotFound, "No updater for deployment type", err + } + + plan, err := updater.PrepareUpdate(ctx, updates.UpdateRequest{ + Version: version, + Channel: channel, + }) + if err != nil { + return updates.UpdatePlan{}, http.StatusInternalServerError, "Failed to prepare update plan", err + } + + normalizedPlan := h.attachUpdateReadiness(ctx, version, plan.NormalizeCollections()) + return normalizedPlan, 0, "", nil } func (h *UpdateHandlers) attachUpdateReadiness(ctx context.Context, version string, plan updates.UpdatePlan) updates.UpdatePlan { @@ -439,6 +472,10 @@ func (h *UpdateHandlers) attachUpdateReadiness(ctx context.Context, version stri return plan.NormalizeCollections() } +func (h *UpdateHandlers) hasUpdateReadinessSources() bool { + return h != nil && h.getConfig != nil && h.getHostsSnapshot != nil +} + func fallbackManualUpdatePlan(deploymentType string, version string) (*updates.UpdatePlan, bool) { normalized := strings.ToLower(strings.TrimSpace(deploymentType)) diff --git a/internal/api/updates_test.go b/internal/api/updates_test.go index 09aadd943..ca3ead4c0 100644 --- a/internal/api/updates_test.go +++ b/internal/api/updates_test.go @@ -266,6 +266,64 @@ func TestHandleApplyUpdate_StableRejectsPrereleaseTarget(t *testing.T) { } } +func TestHandleApplyUpdate_BlocksWhenReadinessBlocked(t *testing.T) { + setMockModeForTest(t, true) + + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + applyCalled := false + mockManager := &MockUpdateManager{ + ApplyUpdateFunc: func(ctx context.Context, req updates.ApplyUpdateRequest) error { + applyCalled = true + return nil + }, + } + + h := NewUpdateHandlers(mockManager, nil) + h.now = func() time.Time { return now } + h.SetUpdateReadinessSources( + func(context.Context) *config.Config { + return &config.Config{} + }, + func(context.Context) []models.Host { + return []models.Host{{ + ID: "host-1", + Hostname: "host-1", + LastSeen: now.Add(-30 * time.Second), + AgentVersion: "5.1.23", + IsLegacy: true, + }} + }, + ) + h.registry.Register("mock", &mockUpdater{ + prepareFunc: func(ctx context.Context, req updates.UpdateRequest) (*updates.UpdatePlan, error) { + if req.Version != "v6.0.0" { + t.Fatalf("PrepareUpdate version = %q, want v6.0.0", req.Version) + } + return &updates.UpdatePlan{ + CanAutoUpdate: true, + RollbackSupport: true, + Instructions: []string{"install"}, + }, nil + }, + }) + + w := httptest.NewRecorder() + body := `{"downloadUrl":"https://github.com/rcourtman/Pulse/releases/download/v6.0.0/pulse-v6.0.0-linux-amd64.tar.gz"}` + r := httptest.NewRequest(http.MethodPost, "/updates/apply?channel=stable", strings.NewReader(body)) + + h.HandleApplyUpdate(w, r) + + if w.Code != http.StatusConflict { + t.Fatalf("Expected status %d, got %d: %s", http.StatusConflict, w.Code, w.Body.String()) + } + if applyCalled { + t.Fatal("ApplyUpdate should not be called when readiness is blocked") + } + if !strings.Contains(w.Body.String(), "Resolve 1 blocked upgrade check") { + t.Fatalf("expected readiness summary, got %q", w.Body.String()) + } +} + func TestHandleUpdateStatus_Fresh(t *testing.T) { mockManager := &MockUpdateManager{ GetStatusFunc: func() updates.UpdateStatus { diff --git a/internal/updates/manager.go b/internal/updates/manager.go index fa43a1aa1..9e54249c6 100644 --- a/internal/updates/manager.go +++ b/internal/updates/manager.go @@ -535,7 +535,7 @@ func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error m.updateStatus("downloading", 10, "Downloading update...") channel := m.resolveChannel(req.Channel, currentInfo) - targetVersion, validationErr := validateApplyTargetVersion(channel, req.DownloadURL) + targetVersion, validationErr := ValidateApplyTargetVersion(channel, req.DownloadURL) if validationErr != nil { return validationErr } @@ -1072,7 +1072,9 @@ func inferVersionFromDownloadURL(downloadURL string) string { return "" } -func validateApplyTargetVersion(channel string, downloadURL string) (string, error) { +// ValidateApplyTargetVersion extracts and validates the target release from an +// update download URL using the same channel rules enforced by the updater. +func ValidateApplyTargetVersion(channel string, downloadURL string) (string, error) { targetVersion := inferVersionFromDownloadURL(downloadURL) if targetVersion == "" { return "", fmt.Errorf("invalid download URL") diff --git a/internal/updates/manager_more_test.go b/internal/updates/manager_more_test.go index a54ed4030..c28db0fb1 100644 --- a/internal/updates/manager_more_test.go +++ b/internal/updates/manager_more_test.go @@ -54,7 +54,7 @@ func TestResolveChannel(t *testing.T) { } func TestValidateApplyTargetVersion(t *testing.T) { - target, err := validateApplyTargetVersion("stable", "https://github.com/rcourtman/Pulse/releases/download/v6.0.0/pulse-v6.0.0-linux-amd64.tar.gz") + target, err := ValidateApplyTargetVersion("stable", "https://github.com/rcourtman/Pulse/releases/download/v6.0.0/pulse-v6.0.0-linux-amd64.tar.gz") if err != nil { t.Fatalf("stable release should be accepted, got %v", err) } @@ -62,19 +62,19 @@ func TestValidateApplyTargetVersion(t *testing.T) { t.Fatalf("unexpected target version %q", target) } - if _, err := validateApplyTargetVersion("stable", "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-v6.0.0-rc.1-linux-amd64.tar.gz"); err == nil { + if _, err := ValidateApplyTargetVersion("stable", "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-v6.0.0-rc.1-linux-amd64.tar.gz"); err == nil { t.Fatal("expected stable channel to reject prerelease target") } - if _, err := validateApplyTargetVersion("rc", "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-v6.0.0-rc.1-linux-amd64.tar.gz"); err != nil { + if _, err := ValidateApplyTargetVersion("rc", "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-v6.0.0-rc.1-linux-amd64.tar.gz"); err != nil { t.Fatalf("rc channel should accept prerelease target, got %v", err) } - if _, err := validateApplyTargetVersion("stable", "https://github.com/rcourtman/Pulse/releases/download/latest/install.sh"); err == nil { + if _, err := ValidateApplyTargetVersion("stable", "https://github.com/rcourtman/Pulse/releases/download/latest/install.sh"); err == nil { t.Fatal("expected invalid download URL to be rejected") } - target, err = validateApplyTargetVersion("stable", "https://github.com/rcourtman/Pulse/releases/download/v6.0.0/pulse-linux-amd64.tar.gz?download=1#v9.9.9") + target, err = ValidateApplyTargetVersion("stable", "https://github.com/rcourtman/Pulse/releases/download/v6.0.0/pulse-linux-amd64.tar.gz?download=1#v9.9.9") if err != nil { t.Fatalf("version should be inferred from URL path, got %v", err) } diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 6a66d10d1..6ede4214a 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -1339,7 +1339,7 @@ func TestSetupUpdateCommandHonorsRCChannelAndCustomPaths(t *testing.T) { if !strings.Contains(got, `helper_args=()`) || !strings.Contains(got, `helper_args=("$@")`) { t.Fatalf("update helper missing passthrough helper args:\n%s", got) } - if !strings.Contains(got, `-h|--help|--uninstall|--version|--rc|--pre|--stable|--source|--from-source|--branch|--archive|--archive=*)`) { + if !strings.Contains(got, `-h|--help|--uninstall|--version|--rc|--pre|--stable|--source|--from-source|--branch|--archive|--archive=*|--skip-upgrade-preflight)`) { t.Fatalf("update helper missing auto-selector guard for explicit flags:\n%s", got) } if !strings.Contains(got, `extra_args+=("${helper_args[@]}")`) { diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index 7f3619730..edf84d1c2 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -190,6 +190,70 @@ func TestRootInstallScriptUpdateDiskHeadroomAcceptsSeparateFilesystems(t *testin } } +func TestRootInstallScriptV5ToV6PreflightWarnsWhenAgentScopeMissing(t *testing.T) { + configDir := t.TempDir() + if err := os.WriteFile(filepath.Join(configDir, "api_tokens.json"), []byte(`[{"id":"tok-1","name":"admin","hash":"hash","scopes":["settings:read"]}]`), 0600); err != nil { + t.Fatalf("write api_tokens.json: %v", err) + } + + script := ` + set -euo pipefail + print_error() { echo "ERROR: $*"; } + print_info() { echo "INFO: $*"; } + print_warn() { echo "WARN: $*"; } + print_success() { echo "SUCCESS: $*"; } + UPGRADE_PREFLIGHT_RAN=false + SKIP_UPGRADE_PREFLIGHT=false +` + extractRootInstallShellFunction(t, "version_major") + ` +` + extractRootInstallShellFunction(t, "is_pre_v6_to_v6_upgrade") + ` +` + extractRootInstallShellFunction(t, "inspect_api_tokens_for_upgrade") + ` +` + extractRootInstallShellFunction(t, "run_upgrade_readiness_preflight") + ` + run_upgrade_readiness_preflight v5.1.23 v6.0.0 + ` + + cmd := exec.Command("bash", "-c", script) + cmd.Env = append(os.Environ(), "CONFIG_DIR="+configDir) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("bash: %v\n%s", err, out) + } + if !strings.Contains(string(out), "No agent reporting token scope was found") { + t.Fatalf("expected missing-scope warning, got:\n%s", out) + } +} + +func TestRootInstallScriptV5ToV6PreflightAcceptsLegacyHostAgentScope(t *testing.T) { + configDir := t.TempDir() + if err := os.WriteFile(filepath.Join(configDir, "api_tokens.json"), []byte(`[{"id":"tok-1","name":"agent","hash":"hash","scopes":["host-agent:report"]}]`), 0600); err != nil { + t.Fatalf("write api_tokens.json: %v", err) + } + + script := ` + set -euo pipefail + print_error() { echo "ERROR: $*"; } + print_info() { echo "INFO: $*"; } + print_warn() { echo "WARN: $*"; } + print_success() { echo "SUCCESS: $*"; } + UPGRADE_PREFLIGHT_RAN=false + SKIP_UPGRADE_PREFLIGHT=false +` + extractRootInstallShellFunction(t, "version_major") + ` +` + extractRootInstallShellFunction(t, "is_pre_v6_to_v6_upgrade") + ` +` + extractRootInstallShellFunction(t, "inspect_api_tokens_for_upgrade") + ` +` + extractRootInstallShellFunction(t, "run_upgrade_readiness_preflight") + ` + run_upgrade_readiness_preflight v5.1.23 v6.0.0 + ` + + cmd := exec.Command("bash", "-c", script) + cmd.Env = append(os.Environ(), "CONFIG_DIR="+configDir) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("bash: %v\n%s", err, out) + } + if !strings.Contains(string(out), "Agent reporting token scope is present") { + t.Fatalf("expected success output, got:\n%s", out) + } +} + func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) { content, err := os.ReadFile(filepath.Join("..", "..", "install.sh")) if err != nil { diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 776b1c640..132f5c6ed 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2860,7 +2860,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 281, + "line": 283, "heading_line": 112, } ],