Enforce upgrade readiness before v6 updates
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run

Refs #1476
This commit is contained in:
rcourtman 2026-05-28 17:22:01 +01:00
parent e1d1ec1d7c
commit 75d1a0477b
12 changed files with 412 additions and 59 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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))

View file

@ -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 {

View file

@ -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")

View file

@ -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)
}

View file

@ -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[@]}")`) {

View file

@ -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 {

View file

@ -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,
}
],