mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
e1d1ec1d7c
commit
75d1a0477b
12 changed files with 412 additions and 59 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
176
install.sh
176
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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[@]}")`) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue