mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-22 14:50:40 +00:00
Let a paid community install switch to the Pro runtime (#2949)
A paid key on the public community build unlocks only the licence-gated features compiled into it. Patrol modes, container update risk, Audit, RBAC and Reporting need the private Pulse Pro runtime, and until now the only route there was the download portal. Licence data from 14 October shows 6 of 36 v6 buyers and 39 of 109 migrated v5 licences still running only the community build. This adds the backend for an in-app switch: - `GET /api/updates/pro-runtime` (admin, `settings:read`) reports whether this install can switch, and how. The license server download broker decides eligibility from the activation's licence. It is the same broker the Pro binary already updates from. Without an activation the check answers `not_activated` with no network call. - `POST /api/updates/pro-runtime/apply` (admin, `settings:write`) installs the Pro build in place on systemd and Proxmox LXC installs. It goes through `ApplyUpdate` with an explicit `SwitchToProRuntime` flag, so ordinary community updates never take the broker path. It keeps the signed archive, manifest hash, pre-install self-test, backup, readiness gate and history rollback of a normal update. It accepts the Pro build of the running version or newer and refuses an older pin. - Docker installs get the broker's digest-pinned image commands in `docker` instead. The Settings → Updates card that uses these routes follows in a separate pull request. Tests: `internal/updates` (switch check and apply against a fake broker), handler, scope, inventory and payload snapshot tests in `internal/api`, the mutation registry audit, and the API reference check. The full `internal/api` and `pkg` suites ran on pulse-dev. The only failures were `TestServerInfoEndpointReportsDevelopment`, which fails in any tree without `.git`, and a load-timing configapi test that passed three times when run alone.
This commit is contained in:
commit
aa9548cf78
21 changed files with 843 additions and 23 deletions
|
|
@ -1278,6 +1278,15 @@ Returns scheduler health, DLQ, and breaker status. Requires authentication (sess
|
|||
- `GET /api/updates/plan?version=X.Y.Z` (optional `channel`, accepts `v` prefix)
|
||||
- `GET /api/updates/history`
|
||||
- `GET /api/updates/history/entry?id=<event_id>`
|
||||
- `GET /api/updates/pro-runtime` (`settings:read`): whether this community build
|
||||
can switch to the Pulse Pro runtime. The license server decides eligibility;
|
||||
`reason` explains a switch that is not offered (`not_activated`,
|
||||
`not_eligible`, `older_release`, `unavailable`, ...). Docker installs get the
|
||||
digest-pinned image commands in `docker`.
|
||||
- `POST /api/updates/pro-runtime/apply` (`settings:write`): installs the Pulse
|
||||
Pro runtime in place through the same verified, backed-up apply as an update
|
||||
(systemd and Proxmox LXC installs only). Returns `409` when no in-place switch
|
||||
is offered.
|
||||
|
||||
### Infrastructure Updates
|
||||
- `GET /api/infra-updates` (requires `monitoring:read`)
|
||||
|
|
|
|||
|
|
@ -9803,3 +9803,11 @@ sources through a runtime transition. Production collection/late-reply and
|
|||
publication-order controls are in `guest_poll_ownership_test.go`; diagnostics
|
||||
and both History stores are covered in `diagnostic_snapshots_test.go`. These
|
||||
controls establish no native guest restart, thaw or prior-workload restoration.
|
||||
|
||||
### Pulse Pro runtime switch routes (14 October 2026)
|
||||
|
||||
`GET /api/updates/pro-runtime` and `POST /api/updates/pro-runtime/apply` in
|
||||
`internal/api/updates.go` replace only the Pulse server binary through the
|
||||
existing update pipeline. They add no agent enrollment, command, install or
|
||||
reporting path, and agent continuity stays judged by the same update readiness
|
||||
verdict the apply route already computes for its target version.
|
||||
|
|
|
|||
|
|
@ -7831,6 +7831,12 @@ cannot be registered or shadowed by extensions. Docker update/control,
|
|||
Kubernetes scale/restart/delete, arbitrary exec, raw command, and file writes
|
||||
are omitted from offered schemas and denied if fabricated.
|
||||
|
||||
The Pulse self-update administrative exception `admin.pulse.self-update`
|
||||
covers `POST /api/updates/apply`, `/api/updates/rollback` and
|
||||
`/api/updates/pro-runtime/apply`. The runtime-switch route is the same
|
||||
admin-only manager apply pipeline as an update, with no model-originated,
|
||||
Assistant tool or agent transport path.
|
||||
|
||||
The registry audits enumerate actual registered tool discriminator values and
|
||||
bind mechanically discovered API, job, and transport candidates to one registry
|
||||
disposition. The action lifecycle API surface registers five lifecycle
|
||||
|
|
|
|||
|
|
@ -2672,6 +2672,18 @@ payload shape change when the portal presents compact client rows.
|
|||
and answers with the same started-acknowledgement shape as apply;
|
||||
conflict responses cover pruned or missing backups and Docker
|
||||
deployments, and not-found covers unknown history entries.
|
||||
`GET /api/updates/pro-runtime` (admin plus `settings:read`) answers
|
||||
whether a community build can switch to the Pulse Pro runtime as
|
||||
`{available, reason?, currentVersion, version?, installInPlace, docker?}`,
|
||||
where `reason` is the closed set `already_pro`, `not_activated`,
|
||||
`not_eligible`, `source_build`, `unsupported_deployment`, `older_release`
|
||||
and `unavailable`, and `docker` reuses the `DockerUpdateCommands` shape.
|
||||
`POST /api/updates/pro-runtime/apply` (admin plus `settings:write`) takes
|
||||
no body, re-checks that an in-place switch is offered (otherwise a plain
|
||||
conflict response, with the reason logged rather than returned), runs the
|
||||
same readiness gate as apply against the switch target version, and
|
||||
answers with the apply started-acknowledgement shape. Neither route
|
||||
exposes broker credentials.
|
||||
87. `pkg/aicontracts/fix_execution.go` shared with `ai-runtime`: the public approved-fix execution contract is both an AI runtime approved-action boundary and a canonical API dependency contract for Patrol and enterprise auto-fix binders.
|
||||
88. `pkg/aicontracts/investigation.go` shared with `ai-runtime`: the public Patrol investigation record and finding contract is both an AI runtime handoff boundary and a canonical API payload contract for Patrol, Assistant, unified findings, persistence, and audit surfaces.
|
||||
89. `pkg/aicontracts/orchestrator_deps.go` shared with `ai-runtime`: the public investigation orchestrator dependency contract is both an AI runtime handoff boundary and a canonical API payload contract for Assistant and Patrol tool-call history.
|
||||
|
|
|
|||
|
|
@ -3082,6 +3082,22 @@ artifact-selection behaviour.
|
|||
affordance for auto-updatable Pro deployments (the broker path preserves
|
||||
the Pro runtime) and surfaces the portal path for deployments the updater
|
||||
cannot drive, such as Docker.
|
||||
The one sanctioned way a community binary moves onto the Pro runtime from
|
||||
inside Pulse is the explicit runtime switch in
|
||||
`internal/updates/pro_runtime_switch.go` (`GET /api/updates/pro-runtime`,
|
||||
`POST /api/updates/pro-runtime/apply`). It never infers a switch from
|
||||
licence state on an ordinary update: only a request carrying
|
||||
`SwitchToProRuntime` takes the broker path on the community edition. The
|
||||
broker decides eligibility from the activation's licence (a 401/403 is
|
||||
reported as `not_eligible`, any other failure as `unavailable`), and the
|
||||
check makes no network call without activation credentials. The in-place
|
||||
switch is limited to systemd and Proxmox LXC deployments, accepts the Pro
|
||||
build of the running version or newer and refuses an older broker pin, and
|
||||
runs the same apply pipeline as an update (signed archive, manifest hash,
|
||||
pre-install self-test, backup, restart), so the recorded update-history
|
||||
entry keeps the normal rollback back to the community build. Docker
|
||||
installs get the broker's digest-pinned Docker command block as
|
||||
`ProRuntimeSwitch.docker` instead, never the community image.
|
||||
A Docker deployment of the compiled Pro binary is part of that same
|
||||
boundary: the container cannot self-replace its binary and a Pro compose
|
||||
file pins the previous image digest, so `internal/updates/pro_update.go`
|
||||
|
|
|
|||
|
|
@ -6836,3 +6836,11 @@ The accepted upstream `diagnostics_token_visibility_test.go` controls and API
|
|||
reference route checks cover caller filtering and the documented authority.
|
||||
Final-source checks must retain this upstream behavior beside the reviewed
|
||||
reporting/help repairs; source proof is not installed credential recovery.
|
||||
|
||||
### Pulse Pro runtime switch opens no storage or recovery path (14 October 2026)
|
||||
|
||||
The Pulse Pro runtime switch routes added under `internal/api/` reuse the
|
||||
update pipeline's existing backup of the installed binary before replacing it
|
||||
and its existing history-entry rollback. The data directory, metrics and
|
||||
configuration stores, backups, snapshots and recovery points are untouched,
|
||||
and no new backup, retention or restore path is added.
|
||||
|
|
|
|||
|
|
@ -1278,6 +1278,15 @@ Returns scheduler health, DLQ, and breaker status. Requires authentication (sess
|
|||
- `GET /api/updates/plan?version=X.Y.Z` (optional `channel`, accepts `v` prefix)
|
||||
- `GET /api/updates/history`
|
||||
- `GET /api/updates/history/entry?id=<event_id>`
|
||||
- `GET /api/updates/pro-runtime` (`settings:read`): whether this community build
|
||||
can switch to the Pulse Pro runtime. The license server decides eligibility;
|
||||
`reason` explains a switch that is not offered (`not_activated`,
|
||||
`not_eligible`, `older_release`, `unavailable`, ...). Docker installs get the
|
||||
digest-pinned image commands in `docker`.
|
||||
- `POST /api/updates/pro-runtime/apply` (`settings:write`): installs the Pulse
|
||||
Pro runtime in place through the same verified, backed-up apply as an update
|
||||
(systemd and Proxmox LXC installs only). Returns `409` when no in-place switch
|
||||
is offered.
|
||||
|
||||
### Infrastructure Updates
|
||||
- `GET /api/infra-updates` (requires `monitoring:read`)
|
||||
|
|
|
|||
|
|
@ -231,6 +231,22 @@ describe('docsLinks', () => {
|
|||
expect(handler).toContain('GetAlertManager().ApplyConfigUpdate(body)');
|
||||
});
|
||||
|
||||
it('documents the Pulse Pro runtime switch routes and their scopes', () => {
|
||||
const api = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
|
||||
expect(api).toContain('`GET /api/updates/pro-runtime` (`settings:read`)');
|
||||
expect(api).toContain('`POST /api/updates/pro-runtime/apply` (`settings:write`)');
|
||||
const routes = readFileSync(
|
||||
path.join(repoRoot, 'internal', 'api', 'router_routes_registration.go'),
|
||||
'utf8',
|
||||
);
|
||||
expect(routes).toContain(
|
||||
'"/api/updates/pro-runtime", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead,',
|
||||
);
|
||||
expect(routes).toContain(
|
||||
'"/api/updates/pro-runtime/apply", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite,',
|
||||
);
|
||||
});
|
||||
|
||||
it('documents network discovery without the removed AI Discovery routes', () => {
|
||||
const api = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
|
||||
expect(api).toContain('`POST /api/discover` (admin, `settings:write`)');
|
||||
|
|
|
|||
|
|
@ -10995,6 +10995,67 @@ func TestContract_UpdatePlanManualFallbackJSONSnapshot(t *testing.T) {
|
|||
assertJSONSnapshot(t, got, want)
|
||||
}
|
||||
|
||||
func TestContract_ProRuntimeSwitchJSONSnapshot(t *testing.T) {
|
||||
inPlace, err := json.Marshal(updates.ProRuntimeSwitch{
|
||||
Available: true,
|
||||
CurrentVersion: "6.5.0",
|
||||
Version: "6.5.0",
|
||||
InstallInPlace: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal in-place switch: %v", err)
|
||||
}
|
||||
assertJSONSnapshot(t, inPlace, `{
|
||||
"available":true,
|
||||
"currentVersion":"6.5.0",
|
||||
"version":"6.5.0",
|
||||
"installInPlace":true
|
||||
}`)
|
||||
|
||||
refused, err := json.Marshal(updates.ProRuntimeSwitch{
|
||||
Reason: updates.ProRuntimeSwitchReasonNotEligible,
|
||||
CurrentVersion: "6.5.0",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal refused switch: %v", err)
|
||||
}
|
||||
assertJSONSnapshot(t, refused, `{
|
||||
"available":false,
|
||||
"reason":"not_eligible",
|
||||
"currentVersion":"6.5.0",
|
||||
"installInPlace":false
|
||||
}`)
|
||||
|
||||
docker, err := json.Marshal(updates.ProRuntimeSwitch{
|
||||
Available: true,
|
||||
CurrentVersion: "6.5.0",
|
||||
Version: "6.5.0",
|
||||
Docker: &updates.DockerUpdateCommands{
|
||||
Version: "v6.5.0",
|
||||
Image: "registry.example/pulse-pro",
|
||||
ImageDigest: "sha256:abc",
|
||||
ComposePullCommand: "PULSE_IMAGE='registry.example/pulse-pro@sha256:abc' docker compose pull",
|
||||
ComposeUpCommand: "PULSE_IMAGE='registry.example/pulse-pro@sha256:abc' docker compose up -d",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal docker switch: %v", err)
|
||||
}
|
||||
assertJSONSnapshot(t, docker, `{
|
||||
"available":true,
|
||||
"currentVersion":"6.5.0",
|
||||
"version":"6.5.0",
|
||||
"installInPlace":false,
|
||||
"docker":{
|
||||
"version":"v6.5.0",
|
||||
"image":"registry.example/pulse-pro",
|
||||
"imageDigest":"sha256:abc",
|
||||
"composePullCommand":"PULSE_IMAGE='registry.example/pulse-pro@sha256:abc' docker compose pull",
|
||||
"composeUpCommand":"PULSE_IMAGE='registry.example/pulse-pro@sha256:abc' docker compose up -d"
|
||||
}
|
||||
}`)
|
||||
}
|
||||
|
||||
func TestContract_EmptyUpdatePlanJSONSnapshot(t *testing.T) {
|
||||
payload := updates.EmptyUpdatePlan()
|
||||
|
||||
|
|
|
|||
|
|
@ -464,6 +464,8 @@ var allRouteAllowlist = []string{
|
|||
"/api/updates/release-notes",
|
||||
"/api/updates/stream",
|
||||
"/api/updates/plan",
|
||||
"/api/updates/pro-runtime",
|
||||
"/api/updates/pro-runtime/apply",
|
||||
"/api/updates/history",
|
||||
"/api/updates/history/entry",
|
||||
"/api/infra-updates",
|
||||
|
|
|
|||
|
|
@ -135,6 +135,8 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) {
|
|||
r.mux.HandleFunc("/api/updates/status", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleUpdateStatus)))
|
||||
r.mux.HandleFunc("/api/updates/stream", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleUpdateStream)))
|
||||
r.mux.HandleFunc("/api/updates/plan", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleGetUpdatePlan)))
|
||||
r.mux.HandleFunc("/api/updates/pro-runtime", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleCheckProRuntimeSwitch)))
|
||||
r.mux.HandleFunc("/api/updates/pro-runtime/apply", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, updateHandlers.HandleApplyProRuntimeSwitch)))
|
||||
// Release notes for the running version are readable by any authenticated
|
||||
// user (public GitHub data) so the What's New card works for non-admins.
|
||||
r.mux.HandleFunc("/api/updates/release-notes", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, updateHandlers.HandleGetReleaseNotes)))
|
||||
|
|
|
|||
|
|
@ -1440,6 +1440,7 @@ func TestUpdateReadEndpointsRequireSettingsReadScope(t *testing.T) {
|
|||
"/api/updates/check",
|
||||
"/api/updates/status",
|
||||
"/api/updates/plan",
|
||||
"/api/updates/pro-runtime",
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
|
|
@ -1475,15 +1476,17 @@ func TestUpdateApplyRequiresSettingsWriteScope(t *testing.T) {
|
|||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/updates/apply", strings.NewReader(`{}`))
|
||||
req.Header.Set("X-API-Token", rawToken)
|
||||
rec := httptest.NewRecorder()
|
||||
router.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
||||
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
||||
for _, path := range []string{"/api/updates/apply", "/api/updates/pro-runtime/apply"} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
||||
req.Header.Set("X-API-Token", rawToken)
|
||||
rec := httptest.NewRecorder()
|
||||
router.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s: expected 403 for missing settings:write scope, got %d", path, rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
||||
t.Fatalf("%s: expected missing scope response to mention %q, got %q", path, config.ScopeSettingsWrite, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2917,6 +2920,8 @@ func TestProxyAuthNonAdminDeniedAdminEndpoints(t *testing.T) {
|
|||
{method: http.MethodPost, path: "/api/logs/level", body: `{}`},
|
||||
{method: http.MethodGet, path: "/api/updates/check", body: ""},
|
||||
{method: http.MethodPost, path: "/api/updates/apply", body: `{}`},
|
||||
{method: http.MethodGet, path: "/api/updates/pro-runtime", body: ""},
|
||||
{method: http.MethodPost, path: "/api/updates/pro-runtime/apply", body: `{}`},
|
||||
{method: http.MethodGet, path: "/api/updates/status", body: ""},
|
||||
{method: http.MethodGet, path: "/api/updates/stream", body: ""},
|
||||
{method: http.MethodGet, path: "/api/updates/plan", body: ""},
|
||||
|
|
@ -4065,6 +4070,8 @@ func TestPrivilegedSecurityEndpointsRejectNonAdminSession(t *testing.T) {
|
|||
{method: http.MethodGet, path: "/api/updates/plan", body: ``},
|
||||
{method: http.MethodGet, path: "/api/updates/history", body: ``},
|
||||
{method: http.MethodPost, path: "/api/updates/apply", body: `{}`},
|
||||
{method: http.MethodGet, path: "/api/updates/pro-runtime", body: ``},
|
||||
{method: http.MethodPost, path: "/api/updates/pro-runtime/apply", body: `{}`},
|
||||
{method: http.MethodGet, path: "/api/logs/stream", body: ``},
|
||||
{method: http.MethodGet, path: "/api/logs/download", body: ``},
|
||||
{method: http.MethodGet, path: "/api/settings/relay", body: ``},
|
||||
|
|
|
|||
|
|
@ -292,6 +292,112 @@ func (h *UpdateHandlers) HandleApplyUpdate(w http.ResponseWriter, r *http.Reques
|
|||
}
|
||||
}
|
||||
|
||||
// proRuntimeSwitcher is implemented by update managers that can move a
|
||||
// community install with a paid licence onto the Pulse Pro runtime.
|
||||
type proRuntimeSwitcher interface {
|
||||
CheckProRuntimeSwitch(ctx context.Context, channel string) (*updates.ProRuntimeSwitch, error)
|
||||
ApplyProRuntimeSwitch(ctx context.Context, channel string, initiatedBy updates.InitiatedBy, initiatedVia updates.InitiatedVia) error
|
||||
}
|
||||
|
||||
// HandleCheckProRuntimeSwitch reports whether this install can switch to the
|
||||
// Pulse Pro runtime, and how (in place, or with Docker image commands).
|
||||
func (h *UpdateHandlers) HandleCheckProRuntimeSwitch(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
switcher, ok := h.manager.(proRuntimeSwitcher)
|
||||
if !ok {
|
||||
http.Error(w, "Pulse Pro runtime switch is not supported", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
channel, err := normalizeRequestedUpdateChannel(r.URL.Query().Get("channel"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
result, err := switcher.CheckProRuntimeSwitch(r.Context(), channel)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to check the Pulse Pro runtime switch")
|
||||
http.Error(w, "Failed to check the Pulse Pro runtime switch", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to encode Pulse Pro runtime switch")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleApplyProRuntimeSwitch installs the Pulse Pro runtime over this
|
||||
// community binary. It re-checks eligibility and runs the same readiness gate
|
||||
// as an update before starting.
|
||||
func (h *UpdateHandlers) HandleApplyProRuntimeSwitch(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
switcher, ok := h.manager.(proRuntimeSwitcher)
|
||||
if !ok {
|
||||
http.Error(w, "Pulse Pro runtime switch is not supported", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
channel, err := normalizeRequestedUpdateChannel(r.URL.Query().Get("channel"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
check, err := switcher.CheckProRuntimeSwitch(r.Context(), channel)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to check the Pulse Pro runtime switch before apply")
|
||||
http.Error(w, "Failed to check the Pulse Pro runtime switch", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !check.Available || !check.InstallInPlace {
|
||||
log.Warn().Str("reason", check.Reason).Bool("available", check.Available).Msg("Pulse Pro runtime switch refused: no in-place switch offered")
|
||||
http.Error(w, "Switching to Pulse Pro is not available for this install right now. Reload the page to check again.", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
if h.hasUpdateReadinessSources() {
|
||||
plan, statusCode, msg, err := h.prepareUpdatePlan(r.Context(), check.Version, channel)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("version", check.Version).Msg("Pulse Pro runtime switch readiness request rejected")
|
||||
http.Error(w, msg, statusCode)
|
||||
return
|
||||
}
|
||||
if plan.Readiness != nil && plan.Readiness.Status == updateReadinessBlocked {
|
||||
http.Error(w, plan.Readiness.Summary, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
result <- switcher.ApplyProRuntimeSwitch(context.Background(), channel, updates.InitiatedByUser, updates.InitiatedViaUI)
|
||||
}()
|
||||
select {
|
||||
case err := <-result:
|
||||
if err != nil {
|
||||
statusCode, msg := classifyApplyUpdateStartError(err)
|
||||
log.Warn().Err(err).Str("channel", channel).Msg("Pulse Pro runtime switch rejected")
|
||||
http.Error(w, msg, statusCode)
|
||||
return
|
||||
}
|
||||
case <-time.After(applyUpdateStartAckTimeout):
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "started",
|
||||
"message": "Pulse Pro runtime switch started",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to encode Pulse Pro runtime switch start response")
|
||||
}
|
||||
}
|
||||
|
||||
func decodeStrictJSONBody(body io.Reader, dst any) error {
|
||||
decoder := json.NewDecoder(body)
|
||||
decoder.DisallowUnknownFields()
|
||||
|
|
@ -331,7 +437,10 @@ func classifyApplyUpdateStartError(err error) (int, string) {
|
|||
return http.StatusConflict, err.Error()
|
||||
case strings.Contains(errMsg, "cannot be applied in docker environment"),
|
||||
strings.Contains(errMsg, "manual migration required"),
|
||||
strings.Contains(errMsg, "pulse pro updates need an activated license"):
|
||||
strings.Contains(errMsg, "pulse pro updates need an activated license"),
|
||||
strings.Contains(errMsg, "already runs the pulse pro runtime"),
|
||||
strings.Contains(errMsg, "cannot be installed in place"),
|
||||
strings.Contains(errMsg, "older than this install"):
|
||||
return http.StatusConflict, err.Error()
|
||||
default:
|
||||
return http.StatusInternalServerError, "Failed to start update"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,22 @@ type MockUpdateManager struct {
|
|||
GetSSECachedStatusFunc func() (updates.UpdateStatus, time.Time)
|
||||
AddSSEClientFunc func(w http.ResponseWriter, clientID string) *updates.SSEClient
|
||||
RemoveSSEClientFunc func(clientID string)
|
||||
CheckProRuntimeFunc func(ctx context.Context, channel string) (*updates.ProRuntimeSwitch, error)
|
||||
ApplyProRuntimeFunc func(ctx context.Context, channel string, by updates.InitiatedBy, via updates.InitiatedVia) error
|
||||
}
|
||||
|
||||
func (m *MockUpdateManager) CheckProRuntimeSwitch(ctx context.Context, channel string) (*updates.ProRuntimeSwitch, error) {
|
||||
if m.CheckProRuntimeFunc != nil {
|
||||
return m.CheckProRuntimeFunc(ctx, channel)
|
||||
}
|
||||
return &updates.ProRuntimeSwitch{Reason: updates.ProRuntimeSwitchReasonNotActivated}, nil
|
||||
}
|
||||
|
||||
func (m *MockUpdateManager) ApplyProRuntimeSwitch(ctx context.Context, channel string, by updates.InitiatedBy, via updates.InitiatedVia) error {
|
||||
if m.ApplyProRuntimeFunc != nil {
|
||||
return m.ApplyProRuntimeFunc(ctx, channel, by, via)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockUpdateManager) GetReleaseNotes(ctx context.Context, version string) (*updates.ReleaseNotesInfo, error) {
|
||||
|
|
@ -1274,3 +1290,115 @@ func TestHandleCheckUpdatesFreshness(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleProRuntimeSwitch covers GET /api/updates/pro-runtime and
|
||||
// POST /api/updates/pro-runtime/apply: the apply re-checks eligibility and only
|
||||
// starts an in-place switch.
|
||||
func TestHandleProRuntimeSwitch(t *testing.T) {
|
||||
inPlace := &updates.ProRuntimeSwitch{Available: true, InstallInPlace: true, CurrentVersion: "6.5.0", Version: "6.5.0"}
|
||||
|
||||
t.Run("check returns the manager result", func(t *testing.T) {
|
||||
var gotChannel string
|
||||
h := NewUpdateHandlers(&MockUpdateManager{CheckProRuntimeFunc: func(ctx context.Context, channel string) (*updates.ProRuntimeSwitch, error) {
|
||||
gotChannel = channel
|
||||
return inPlace, nil
|
||||
}}, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCheckProRuntimeSwitch(rec, httptest.NewRequest(http.MethodGet, "/api/updates/pro-runtime?channel=stable", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got updates.ProRuntimeSwitch
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !got.Available || !got.InstallInPlace || got.Version != "6.5.0" || gotChannel != "stable" {
|
||||
t.Fatalf("unexpected result %+v (channel %q)", got, gotChannel)
|
||||
}
|
||||
if rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("check must not be cached, got %q", rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("check rejects other methods and bad channels", func(t *testing.T) {
|
||||
h := NewUpdateHandlers(&MockUpdateManager{}, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCheckProRuntimeSwitch(rec, httptest.NewRequest(http.MethodPost, "/api/updates/pro-runtime", nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("POST status = %d", rec.Code)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.HandleCheckProRuntimeSwitch(rec, httptest.NewRequest(http.MethodGet, "/api/updates/pro-runtime?channel=beta", nil))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad channel status = %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("manager without the switch reports not implemented", func(t *testing.T) {
|
||||
h := NewUpdateHandlers(struct{ UpdateManager }{&MockUpdateManager{}}, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCheckProRuntimeSwitch(rec, httptest.NewRequest(http.MethodGet, "/api/updates/pro-runtime", nil))
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("apply refuses when no in-place switch is offered", func(t *testing.T) {
|
||||
for _, result := range []*updates.ProRuntimeSwitch{
|
||||
{Reason: updates.ProRuntimeSwitchReasonNotEligible},
|
||||
{Available: true, Docker: &updates.DockerUpdateCommands{Version: "v6.5.0"}},
|
||||
} {
|
||||
applied := false
|
||||
h := NewUpdateHandlers(&MockUpdateManager{
|
||||
CheckProRuntimeFunc: func(context.Context, string) (*updates.ProRuntimeSwitch, error) { return result, nil },
|
||||
ApplyProRuntimeFunc: func(context.Context, string, updates.InitiatedBy, updates.InitiatedVia) error {
|
||||
applied = true
|
||||
return nil
|
||||
},
|
||||
}, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleApplyProRuntimeSwitch(rec, httptest.NewRequest(http.MethodPost, "/api/updates/pro-runtime/apply", nil))
|
||||
if rec.Code != http.StatusConflict || applied {
|
||||
t.Fatalf("expected 409 without apply for %+v, got %d (applied=%v)", result, rec.Code, applied)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("apply starts an in-place switch as a user action", func(t *testing.T) {
|
||||
applied := make(chan [2]string, 1)
|
||||
h := NewUpdateHandlers(&MockUpdateManager{
|
||||
CheckProRuntimeFunc: func(context.Context, string) (*updates.ProRuntimeSwitch, error) { return inPlace, nil },
|
||||
ApplyProRuntimeFunc: func(_ context.Context, _ string, by updates.InitiatedBy, via updates.InitiatedVia) error {
|
||||
applied <- [2]string{string(by), string(via)}
|
||||
return nil
|
||||
},
|
||||
}, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleApplyProRuntimeSwitch(rec, httptest.NewRequest(http.MethodPost, "/api/updates/pro-runtime/apply", nil))
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"started"`) {
|
||||
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
select {
|
||||
case got := <-applied:
|
||||
if got != [2]string{string(updates.InitiatedByUser), string(updates.InitiatedViaUI)} {
|
||||
t.Fatalf("initiated as %v", got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("switch was not started")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("apply surfaces an immediate refusal as a conflict", func(t *testing.T) {
|
||||
h := NewUpdateHandlers(&MockUpdateManager{
|
||||
CheckProRuntimeFunc: func(context.Context, string) (*updates.ProRuntimeSwitch, error) { return inPlace, nil },
|
||||
ApplyProRuntimeFunc: func(context.Context, string, updates.InitiatedBy, updates.InitiatedVia) error {
|
||||
return errors.New("the Pulse Pro runtime is at 6.4.0, older than this install's 6.5.0; switch after the next Pulse Pro release")
|
||||
},
|
||||
}, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleApplyProRuntimeSwitch(rec, httptest.NewRequest(http.MethodPost, "/api/updates/pro-runtime/apply", nil))
|
||||
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "older than this install") {
|
||||
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{"id":"admin.inventory.docker-runtime","origin":"api","resource_class":"pulse_administration","resource_kind":"docker-runtime-record","capability":"manage_inventory","entrypoint":"/api/agents/docker/runtimes/* metadata and enrollment","disposition":"administrative_exception","approval_floor":"admin","delivery":"administrative_transaction","verification":"administrative","rollback":"unsupported","residual_owners":["task_12_final_governance"]},
|
||||
{"id":"admin.inventory.kubernetes-cluster","origin":"api","resource_class":"pulse_administration","resource_kind":"kubernetes-cluster-record","capability":"manage_inventory","entrypoint":"/api/agents/kubernetes/clusters/* metadata and enrollment","disposition":"administrative_exception","approval_floor":"admin","delivery":"administrative_transaction","verification":"administrative","rollback":"unsupported","residual_owners":["task_12_final_governance"]},
|
||||
{"id":"admin.patrol.control-settings","origin":"api","resource_class":"pulse_administration","resource_kind":"patrol-config","capability":"set_patrol_control","entrypoint":"PUT /api/ai/patrol/autonomy","disposition":"administrative_exception","approval_floor":"admin","delivery":"administrative_transaction","verification":"administrative","rollback":"unsupported","residual_owners":["task_12_final_governance"]},
|
||||
{"id":"admin.pulse.self-update","origin":"api","resource_class":"pulse_administration","resource_kind":"pulse-server","capability":"self_update","entrypoint":"POST /api/updates/apply and /api/updates/rollback","disposition":"administrative_exception","approval_floor":"admin","delivery":"administrative_transaction","verification":"administrative","rollback":"unsupported","residual_owners":["task_12_final_governance"]},
|
||||
{"id":"admin.pulse.self-update","origin":"api","resource_class":"pulse_administration","resource_kind":"pulse-server","capability":"self_update","entrypoint":"POST /api/updates/apply, /api/updates/rollback and /api/updates/pro-runtime/apply","disposition":"administrative_exception","approval_floor":"admin","delivery":"administrative_transaction","verification":"administrative","rollback":"unsupported","residual_owners":["task_12_final_governance"]},
|
||||
|
||||
{"id":"action.api.decision","origin":"api","resource_class":"customer_infrastructure","resource_kind":"unified-resource","capability":"decide_action","entrypoint":"POST /api/actions/{id}/decision","disposition":"lifecycle","lifecycle_executor":"internal/actionlifecycle.Service.Decide","approval_floor":"admin","delivery":"committed_lifecycle_before_transport","verification":"required","rollback":"task_10_truth_and_compensation","residual_owners":["task_10_truth_and_compensation"]},
|
||||
{"id":"action.api.execute","origin":"api","resource_class":"customer_infrastructure","resource_kind":"unified-resource","capability":"execute_action","entrypoint":"POST /api/actions/{id}/execute","disposition":"lifecycle","lifecycle_executor":"internal/actionlifecycle.Service.Execute","approval_floor":"admin","delivery":"committed_lifecycle_before_transport","verification":"required","rollback":"task_10_truth_and_compensation","residual_owners":["task_07_durable_delivery","task_10_truth_and_compensation"]},
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ var generatedEntries = []Entry{
|
|||
{ID: "admin.inventory.docker-runtime", Origin: Origin("api"), ResourceClass: ResourceClass("pulse_administration"), ResourceKind: "docker-runtime-record", Capability: "manage_inventory", Entrypoint: "/api/agents/docker/runtimes/* metadata and enrollment", Disposition: Disposition("administrative_exception"), LifecycleExecutor: "", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("administrative_transaction"), Verification: VerificationClass("administrative"), Rollback: RollbackClass("unsupported"), ResidualOwners: []string{"task_12_final_governance"}},
|
||||
{ID: "admin.inventory.kubernetes-cluster", Origin: Origin("api"), ResourceClass: ResourceClass("pulse_administration"), ResourceKind: "kubernetes-cluster-record", Capability: "manage_inventory", Entrypoint: "/api/agents/kubernetes/clusters/* metadata and enrollment", Disposition: Disposition("administrative_exception"), LifecycleExecutor: "", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("administrative_transaction"), Verification: VerificationClass("administrative"), Rollback: RollbackClass("unsupported"), ResidualOwners: []string{"task_12_final_governance"}},
|
||||
{ID: "admin.patrol.control-settings", Origin: Origin("api"), ResourceClass: ResourceClass("pulse_administration"), ResourceKind: "patrol-config", Capability: "set_patrol_control", Entrypoint: "PUT /api/ai/patrol/autonomy", Disposition: Disposition("administrative_exception"), LifecycleExecutor: "", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("administrative_transaction"), Verification: VerificationClass("administrative"), Rollback: RollbackClass("unsupported"), ResidualOwners: []string{"task_12_final_governance"}},
|
||||
{ID: "admin.pulse.self-update", Origin: Origin("api"), ResourceClass: ResourceClass("pulse_administration"), ResourceKind: "pulse-server", Capability: "self_update", Entrypoint: "POST /api/updates/apply and /api/updates/rollback", Disposition: Disposition("administrative_exception"), LifecycleExecutor: "", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("administrative_transaction"), Verification: VerificationClass("administrative"), Rollback: RollbackClass("unsupported"), ResidualOwners: []string{"task_12_final_governance"}},
|
||||
{ID: "admin.pulse.self-update", Origin: Origin("api"), ResourceClass: ResourceClass("pulse_administration"), ResourceKind: "pulse-server", Capability: "self_update", Entrypoint: "POST /api/updates/apply, /api/updates/rollback and /api/updates/pro-runtime/apply", Disposition: Disposition("administrative_exception"), LifecycleExecutor: "", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("administrative_transaction"), Verification: VerificationClass("administrative"), Rollback: RollbackClass("unsupported"), ResidualOwners: []string{"task_12_final_governance"}},
|
||||
{ID: "assistant.docker.control", Origin: Origin("model"), ResourceClass: ResourceClass("customer_infrastructure"), ResourceKind: "app-container", Capability: "start_stop_restart", Entrypoint: "pulse_docker action=control", Disposition: Disposition("retired_denied"), LifecycleExecutor: "", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("denied_before_transport"), Verification: VerificationClass("denied"), Rollback: RollbackClass("denied"), ResidualOwners: []string{"task_05_typed_lifecycle_migration"}},
|
||||
{ID: "assistant.docker.update", Origin: Origin("model"), ResourceClass: ResourceClass("customer_infrastructure"), ResourceKind: "app-container", Capability: "update_container", Entrypoint: "pulse_docker action=update", Disposition: Disposition("retired_denied"), LifecycleExecutor: "", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("denied_before_transport"), Verification: VerificationClass("denied"), Rollback: RollbackClass("denied"), ResidualOwners: []string{"task_07_durable_delivery", "task_10_truth_and_compensation"}},
|
||||
{ID: "assistant.file.append", Origin: Origin("model"), ResourceClass: ResourceClass("customer_infrastructure"), ResourceKind: "remote-file", Capability: "append_file", Entrypoint: "pulse_file_edit action=append", Disposition: Disposition("retired_denied"), LifecycleExecutor: "", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("denied_before_transport"), Verification: VerificationClass("denied"), Rollback: RollbackClass("denied"), ResidualOwners: []string{"retired_no_replacement"}},
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ var infrastructureRouteCatalog = map[string]routeClassification{
|
|||
"/api/updates/status": {},
|
||||
"/api/updates/stream": {},
|
||||
"/api/updates/plan": {},
|
||||
"/api/updates/pro-runtime": {},
|
||||
"/api/updates/pro-runtime/apply": {MutationID: "admin.pulse.self-update"},
|
||||
"/api/updates/history": {},
|
||||
"/api/updates/history/entry": {},
|
||||
"/api/updates/release-notes": {},
|
||||
|
|
|
|||
|
|
@ -269,6 +269,10 @@ type ApplyUpdateRequest struct {
|
|||
// release asset URL cannot silently downgrade; sanctioned rollbacks go
|
||||
// through RollbackToBackup instead.
|
||||
AllowDowngrade bool
|
||||
// SwitchToProRuntime installs the Pulse Pro runtime from the license
|
||||
// server download broker over this community binary (see
|
||||
// ApplyProRuntimeSwitch). DownloadURL is not used.
|
||||
SwitchToProRuntime bool
|
||||
}
|
||||
|
||||
// RollbackRequest describes a rollback of a recorded update, restoring the
|
||||
|
|
@ -628,7 +632,11 @@ func updateWarning(available, isMajorUpgrade, isPrerelease bool, currentMajor, l
|
|||
|
||||
// ApplyUpdate downloads and applies an update
|
||||
func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error {
|
||||
if req.DownloadURL == "" {
|
||||
switchToPro := req.SwitchToProRuntime
|
||||
if switchToPro && edition.IsPro() {
|
||||
return fmt.Errorf("this install already runs the Pulse Pro runtime")
|
||||
}
|
||||
if req.DownloadURL == "" && !switchToPro {
|
||||
return fmt.Errorf("download URL is required")
|
||||
}
|
||||
|
||||
|
|
@ -636,16 +644,20 @@ func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error
|
|||
// community build (it would silently strip Audit, RBAC, Reporting, and
|
||||
// SSO), so it updates through the license server download broker instead.
|
||||
// This keys off the compiled edition, not license state: a community
|
||||
// binary with an active license is still community and updates normally.
|
||||
isPro := edition.IsPro()
|
||||
// binary with an active license is still community and updates normally,
|
||||
// unless the request explicitly switches it to the Pro runtime.
|
||||
useBroker := edition.IsPro() || switchToPro
|
||||
var validatedDownloadURL *url.URL
|
||||
if isPro {
|
||||
if useBroker {
|
||||
creds, ok := m.proUpdateCredentials()
|
||||
if !ok {
|
||||
return errProUpdateNotActivated()
|
||||
}
|
||||
if err := validateProApplyRequestURL(req.DownloadURL, creds.LicenseServerURL); err != nil {
|
||||
return err
|
||||
// A switch carries no download URL: the broker is the only source.
|
||||
if !switchToPro {
|
||||
if err := validateProApplyRequestURL(req.DownloadURL, creds.LicenseServerURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var validationErr error
|
||||
|
|
@ -661,6 +673,9 @@ func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error
|
|||
if currentInfo.IsDocker {
|
||||
return fmt.Errorf("updates cannot be applied in Docker environment")
|
||||
}
|
||||
if switchToPro && (currentInfo.IsSourceBuild || proRuntimeSwitchMode(currentInfo) != proRuntimeSwitchModeInPlace) {
|
||||
return fmt.Errorf("the Pulse Pro runtime cannot be installed in place on a %s deployment; use https://pulserelay.pro/download.html", currentInfo.DeploymentType)
|
||||
}
|
||||
|
||||
// Check for pre-v4 installation
|
||||
if isPreV4Installation() {
|
||||
|
|
@ -685,7 +700,7 @@ func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error
|
|||
|
||||
channel := m.resolveChannel(req.Channel, currentInfo)
|
||||
var artifact resolvedUpdateArtifact
|
||||
if isPro {
|
||||
if useBroker {
|
||||
// Resolve fresh signed URLs from the broker at apply time: the URLs it
|
||||
// hands out expire in minutes, so the check-time response is never
|
||||
// reused here.
|
||||
|
|
@ -707,8 +722,14 @@ func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error
|
|||
// A valid release asset URL can still point at an older release than the
|
||||
// running binary; without this guard the "update" would silently install a
|
||||
// downgrade. Sanctioned downgrades either restore a retained backup via
|
||||
// RollbackToBackup or set AllowDowngrade explicitly on the request.
|
||||
if !req.AllowDowngrade {
|
||||
// RollbackToBackup or set AllowDowngrade explicitly on the request. A
|
||||
// runtime switch accepts the same version but never an older one.
|
||||
if switchToPro {
|
||||
if err := ensureSwitchTargetIsNotOlder(currentInfo.Version, artifact.version); err != nil {
|
||||
m.updateStatus("error", 10, "Switch rejected", err)
|
||||
return err
|
||||
}
|
||||
} else if !req.AllowDowngrade {
|
||||
if err := ensureApplyTargetIsNewer(currentInfo.Version, artifact.version); err != nil {
|
||||
m.updateStatus("error", 10, "Update rejected", err)
|
||||
return err
|
||||
|
|
|
|||
174
internal/updates/pro_runtime_switch.go
Normal file
174
internal/updates/pro_runtime_switch.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/edition"
|
||||
)
|
||||
|
||||
// A paid licence activated on the public community build unlocks only the
|
||||
// licence-gated features compiled into that build. Patrol modes, Audit, RBAC,
|
||||
// and Reporting need the separately compiled Pulse Pro runtime. The switch
|
||||
// moves such an install onto the Pro runtime through the same license server
|
||||
// download broker the Pro binary updates from: in place for installs the
|
||||
// in-app updater already applies to, and as the broker's digest-pinned image
|
||||
// commands for Docker. The broker decides eligibility from the licence, so a
|
||||
// community install without a qualifying licence is refused there.
|
||||
|
||||
// Reasons a switch is not offered. The frontend renders copy per reason.
|
||||
const (
|
||||
ProRuntimeSwitchReasonAlreadyPro = "already_pro"
|
||||
ProRuntimeSwitchReasonNotActivated = "not_activated"
|
||||
ProRuntimeSwitchReasonNotEligible = "not_eligible"
|
||||
ProRuntimeSwitchReasonSourceBuild = "source_build"
|
||||
ProRuntimeSwitchReasonUnsupported = "unsupported_deployment"
|
||||
ProRuntimeSwitchReasonOlderRelease = "older_release"
|
||||
ProRuntimeSwitchReasonUnavailable = "unavailable"
|
||||
)
|
||||
|
||||
const (
|
||||
proRuntimeSwitchModeInPlace = "in_place"
|
||||
proRuntimeSwitchModeDocker = "docker"
|
||||
proRuntimeSwitchHistoryNote = "Switch to Pulse Pro"
|
||||
)
|
||||
|
||||
// ProRuntimeSwitch describes whether this install can move to the Pulse Pro
|
||||
// runtime and how.
|
||||
type ProRuntimeSwitch struct {
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
// Version is the private Pro release the switch installs.
|
||||
Version string `json:"version,omitempty"`
|
||||
// InstallInPlace means POST /api/updates/pro-runtime/apply can perform the
|
||||
// switch from the browser.
|
||||
InstallInPlace bool `json:"installInPlace"`
|
||||
// Docker carries the broker's digest-pinned image commands for container
|
||||
// installs, which the in-app updater cannot replace.
|
||||
Docker *DockerUpdateCommands `json:"docker,omitempty"`
|
||||
}
|
||||
|
||||
// proRuntimeSwitchMode reports how a switch would run for this install, or ""
|
||||
// when neither path applies. In-place follows the deployments whose update
|
||||
// plan already applies from the browser; the mock/CI escape hatch mirrors the
|
||||
// rest of the updater.
|
||||
func proRuntimeSwitchMode(info *VersionInfo) string {
|
||||
if info == nil {
|
||||
return ""
|
||||
}
|
||||
if info.IsDocker || info.DeploymentType == "docker" {
|
||||
return proRuntimeSwitchModeDocker
|
||||
}
|
||||
switch info.DeploymentType {
|
||||
case "systemd", "proxmoxve":
|
||||
return proRuntimeSwitchModeInPlace
|
||||
}
|
||||
if dockerUpdatesAllowed() {
|
||||
return proRuntimeSwitchModeInPlace
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isProBrokerRefusal reports a broker reply that says this licence cannot have
|
||||
// Pro downloads (ineligible plan, lapsed licence, unknown credential), as
|
||||
// opposed to an outage worth retrying.
|
||||
func isProBrokerRefusal(err error) bool {
|
||||
var statusErr *proBrokerStatusError
|
||||
if !errors.As(err, &statusErr) {
|
||||
return false
|
||||
}
|
||||
return statusErr.StatusCode == http.StatusForbidden || statusErr.StatusCode == http.StatusUnauthorized
|
||||
}
|
||||
|
||||
// CheckProRuntimeSwitch reports whether this community install can switch to
|
||||
// the Pulse Pro runtime. Expected refusals come back as a Reason, not an error.
|
||||
func (m *Manager) CheckProRuntimeSwitch(ctx context.Context, channel string) (*ProRuntimeSwitch, error) {
|
||||
currentInfo, err := GetCurrentVersion()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
||||
}
|
||||
result := &ProRuntimeSwitch{CurrentVersion: currentInfo.Version}
|
||||
|
||||
if edition.IsPro() {
|
||||
result.Reason = ProRuntimeSwitchReasonAlreadyPro
|
||||
return result, nil
|
||||
}
|
||||
if currentInfo.IsSourceBuild {
|
||||
result.Reason = ProRuntimeSwitchReasonSourceBuild
|
||||
return result, nil
|
||||
}
|
||||
mode := proRuntimeSwitchMode(currentInfo)
|
||||
if mode == "" {
|
||||
result.Reason = ProRuntimeSwitchReasonUnsupported
|
||||
return result, nil
|
||||
}
|
||||
creds, ok := m.proUpdateCredentials()
|
||||
if !ok {
|
||||
result.Reason = ProRuntimeSwitchReasonNotActivated
|
||||
return result, nil
|
||||
}
|
||||
|
||||
manifest, err := m.fetchProDownloadManifest(ctx, creds, m.resolveChannel(channel, currentInfo))
|
||||
if err != nil {
|
||||
if isProBrokerRefusal(err) {
|
||||
result.Reason = ProRuntimeSwitchReasonNotEligible
|
||||
return result, nil
|
||||
}
|
||||
result.Reason = ProRuntimeSwitchReasonUnavailable
|
||||
return result, nil
|
||||
}
|
||||
result.Version = strings.TrimPrefix(strings.TrimSpace(manifest.Release.Version), "v")
|
||||
|
||||
if err := ensureSwitchTargetIsNotOlder(currentInfo.Version, manifest.Release.Version); err != nil {
|
||||
result.Reason = ProRuntimeSwitchReasonOlderRelease
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if mode == proRuntimeSwitchModeDocker {
|
||||
result.Docker = proDockerUpdateCommands(manifest)
|
||||
if result.Docker == nil {
|
||||
result.Reason = ProRuntimeSwitchReasonUnavailable
|
||||
return result, nil
|
||||
}
|
||||
result.Available = true
|
||||
return result, nil
|
||||
}
|
||||
result.InstallInPlace = true
|
||||
result.Available = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ensureSwitchTargetIsNotOlder allows the Pro build of the running version: a
|
||||
// switch changes the runtime, so the same version is the expected target. It
|
||||
// still refuses an older Pro pin, which would be a silent downgrade.
|
||||
func ensureSwitchTargetIsNotOlder(currentVersion, targetVersion string) error {
|
||||
current, err := ParseVersion(currentVersion)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
target, err := ParseVersion(targetVersion)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if target.Compare(current) < 0 {
|
||||
return fmt.Errorf("the Pulse Pro runtime is at %s, older than this install's %s; switch after the next Pulse Pro release", target.String(), current.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyProRuntimeSwitch installs the Pulse Pro runtime over this community
|
||||
// binary through the shared apply pipeline (broker download, signature and
|
||||
// hash checks, pre-install self-test, backup, restart).
|
||||
func (m *Manager) ApplyProRuntimeSwitch(ctx context.Context, channel string, initiatedBy InitiatedBy, initiatedVia InitiatedVia) error {
|
||||
return m.ApplyUpdate(ctx, ApplyUpdateRequest{
|
||||
Channel: channel,
|
||||
InitiatedBy: initiatedBy,
|
||||
InitiatedVia: initiatedVia,
|
||||
Notes: proRuntimeSwitchHistoryNote,
|
||||
SwitchToProRuntime: true,
|
||||
})
|
||||
}
|
||||
213
internal/updates/pro_runtime_switch_test.go
Normal file
213
internal/updates/pro_runtime_switch_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/edition"
|
||||
)
|
||||
|
||||
// setupProRuntimeSwitchTest runs as the community binary: the switch exists to
|
||||
// move a community install with a paid licence onto the Pro runtime.
|
||||
func setupProRuntimeSwitchTest(t *testing.T, currentVersion string) {
|
||||
t.Helper()
|
||||
setupProUpdateTest(t, currentVersion)
|
||||
edition.SetEdition(edition.Community)
|
||||
}
|
||||
|
||||
func refusingBroker(t *testing.T, status int, code string) func() (ProUpdateCredentials, bool) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"code": code, "message": "refused"}})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return func() (ProUpdateCredentials, bool) {
|
||||
return ProUpdateCredentials{LicenseServerURL: server.URL, InstallationToken: "pit_live_test", InstanceFingerprint: "fp-test"}, true
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckProRuntimeSwitch(t *testing.T) {
|
||||
check := func(t *testing.T, source func() (ProUpdateCredentials, bool)) *ProRuntimeSwitch {
|
||||
t.Helper()
|
||||
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
||||
if source != nil {
|
||||
manager.SetProUpdateCredentialSource(source)
|
||||
}
|
||||
result, err := manager.CheckProRuntimeSwitch(context.Background(), "stable")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckProRuntimeSwitch: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
t.Run("same-version Pro build is offered in place", func(t *testing.T) {
|
||||
setupProRuntimeSwitchTest(t, "6.0.5")
|
||||
fixture := newProBrokerFixture(t, "6.0.5", false)
|
||||
result := check(t, fixture.credentialSource())
|
||||
if !result.Available || !result.InstallInPlace || result.Reason != "" {
|
||||
t.Fatalf("expected an in-place switch, got %+v", result)
|
||||
}
|
||||
if result.Version != "6.0.5" || result.CurrentVersion != "6.0.5" {
|
||||
t.Fatalf("unexpected versions %+v", result)
|
||||
}
|
||||
if result.Docker != nil {
|
||||
t.Fatalf("an in-place switch must not carry Docker commands, got %+v", result.Docker)
|
||||
}
|
||||
if fixture.brokerCalls != 1 {
|
||||
t.Fatalf("expected one broker call, got %d", fixture.brokerCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("older Pro pin is not offered", func(t *testing.T) {
|
||||
setupProRuntimeSwitchTest(t, "6.1.0")
|
||||
fixture := newProBrokerFixture(t, "6.0.5", false)
|
||||
result := check(t, fixture.credentialSource())
|
||||
if result.Available || result.Reason != ProRuntimeSwitchReasonOlderRelease || result.Version != "6.0.5" {
|
||||
t.Fatalf("expected older_release with the pinned version, got %+v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Pro binary has nothing to switch", func(t *testing.T) {
|
||||
setupProUpdateTest(t, "6.0.5")
|
||||
fixture := newProBrokerFixture(t, "6.0.5", false)
|
||||
result := check(t, fixture.credentialSource())
|
||||
if result.Available || result.Reason != ProRuntimeSwitchReasonAlreadyPro {
|
||||
t.Fatalf("expected already_pro, got %+v", result)
|
||||
}
|
||||
if fixture.brokerCalls != 0 {
|
||||
t.Fatalf("a Pro binary must not query the broker for a switch, got %d calls", fixture.brokerCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no activation is reported without a broker call", func(t *testing.T) {
|
||||
setupProRuntimeSwitchTest(t, "6.0.5")
|
||||
result := check(t, nil)
|
||||
if result.Available || result.Reason != ProRuntimeSwitchReasonNotActivated {
|
||||
t.Fatalf("expected not_activated, got %+v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("broker refusal means the licence is not eligible", func(t *testing.T) {
|
||||
setupProRuntimeSwitchTest(t, "6.0.5")
|
||||
result := check(t, refusingBroker(t, http.StatusForbidden, "PRO_DOWNLOAD_NOT_ALLOWED"))
|
||||
if result.Available || result.Reason != ProRuntimeSwitchReasonNotEligible {
|
||||
t.Fatalf("expected not_eligible, got %+v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("broker outage is unavailable, not ineligible", func(t *testing.T) {
|
||||
setupProRuntimeSwitchTest(t, "6.0.5")
|
||||
result := check(t, refusingBroker(t, http.StatusServiceUnavailable, "DOWNLOADS_UNAVAILABLE"))
|
||||
if result.Available || result.Reason != ProRuntimeSwitchReasonUnavailable {
|
||||
t.Fatalf("expected unavailable, got %+v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wire keys", func(t *testing.T) {
|
||||
encoded, err := json.Marshal(ProRuntimeSwitch{Available: true, CurrentVersion: "6.0.5", Version: "6.0.5", InstallInPlace: true})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
for _, key := range []string{`"available":true`, `"currentVersion":"6.0.5"`, `"version":"6.0.5"`, `"installInPlace":true`} {
|
||||
if !strings.Contains(string(encoded), key) {
|
||||
t.Fatalf("ProRuntimeSwitch JSON missing %s: %s", key, encoded)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProRuntimeSwitchMode(t *testing.T) {
|
||||
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "")
|
||||
cases := []struct {
|
||||
info *VersionInfo
|
||||
want string
|
||||
}{
|
||||
{&VersionInfo{IsDocker: true, DeploymentType: "docker"}, proRuntimeSwitchModeDocker},
|
||||
{&VersionInfo{DeploymentType: "docker"}, proRuntimeSwitchModeDocker},
|
||||
{&VersionInfo{DeploymentType: "systemd"}, proRuntimeSwitchModeInPlace},
|
||||
{&VersionInfo{DeploymentType: "proxmoxve"}, proRuntimeSwitchModeInPlace},
|
||||
{&VersionInfo{DeploymentType: "aur"}, ""},
|
||||
{&VersionInfo{DeploymentType: "manual"}, ""},
|
||||
{nil, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := proRuntimeSwitchMode(tc.info); got != tc.want {
|
||||
t.Errorf("proRuntimeSwitchMode(%+v) = %q, want %q", tc.info, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProRuntimeSwitch(t *testing.T) {
|
||||
origVerify := verifyReleaseSignatureFunc
|
||||
verifyReleaseSignatureFunc = func(ctx context.Context, targetPath, signaturePath string) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { verifyReleaseSignatureFunc = origVerify })
|
||||
|
||||
t.Run("community binary installs the same-version Pro build from the broker", func(t *testing.T) {
|
||||
setupProRuntimeSwitchTest(t, "6.0.5")
|
||||
fixture := newProBrokerFixture(t, "6.0.5", false)
|
||||
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
||||
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
||||
|
||||
err := manager.ApplyProRuntimeSwitch(context.Background(), "stable", InitiatedByUser, InitiatedViaUI)
|
||||
// The dummy tarball has no pulse binary, so reaching that check proves
|
||||
// download, signature and hash verification all ran on the Pro artifact.
|
||||
if err == nil || !strings.Contains(err.Error(), "pulse binary not found") {
|
||||
t.Fatalf("expected failure at the pre-install validation stage, got: %v", err)
|
||||
}
|
||||
if fixture.brokerCalls == 0 || fixture.tarballCalls != 1 || fixture.sshsigCalls != 1 {
|
||||
t.Fatalf("expected broker, artifact and signature fetches, got broker=%d tarball=%d sshsig=%d",
|
||||
fixture.brokerCalls, fixture.tarballCalls, fixture.sshsigCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("older Pro pin is refused before download", func(t *testing.T) {
|
||||
setupProRuntimeSwitchTest(t, "6.1.0")
|
||||
fixture := newProBrokerFixture(t, "6.0.5", false)
|
||||
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
||||
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
||||
|
||||
err := manager.ApplyProRuntimeSwitch(context.Background(), "stable", InitiatedByUser, InitiatedViaUI)
|
||||
if err == nil || !strings.Contains(err.Error(), "older than this install") {
|
||||
t.Fatalf("expected the older-release refusal, got: %v", err)
|
||||
}
|
||||
if fixture.tarballCalls != 0 {
|
||||
t.Fatalf("an older pin must not be downloaded, got %d downloads", fixture.tarballCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Pro binary refuses a switch", func(t *testing.T) {
|
||||
setupProUpdateTest(t, "6.0.5")
|
||||
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
||||
err := manager.ApplyProRuntimeSwitch(context.Background(), "stable", InitiatedByUser, InitiatedViaUI)
|
||||
if err == nil || !strings.Contains(err.Error(), "already runs the Pulse Pro runtime") {
|
||||
t.Fatalf("expected already-Pro refusal, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("switch needs an activation", func(t *testing.T) {
|
||||
setupProRuntimeSwitchTest(t, "6.0.5")
|
||||
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
||||
err := manager.ApplyProRuntimeSwitch(context.Background(), "stable", InitiatedByUser, InitiatedViaUI)
|
||||
if err == nil || !strings.Contains(err.Error(), "need an activated license") {
|
||||
t.Fatalf("expected the activation refusal, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ordinary community apply still requires a download URL", func(t *testing.T) {
|
||||
setupProRuntimeSwitchTest(t, "6.0.5")
|
||||
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
||||
err := manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{Channel: "stable"})
|
||||
if err == nil || !strings.Contains(err.Error(), "download URL is required") {
|
||||
t.Fatalf("expected the download URL requirement, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -166,6 +166,18 @@ type proBrokerErrorResponse struct {
|
|||
} `json:"error"`
|
||||
}
|
||||
|
||||
// proBrokerStatusError is a non-200 broker reply. It keeps the broker's error
|
||||
// code so callers can tell a licence the broker refuses (403) from an outage.
|
||||
type proBrokerStatusError struct {
|
||||
StatusCode int
|
||||
Code string
|
||||
detail string
|
||||
}
|
||||
|
||||
func (e *proBrokerStatusError) Error() string {
|
||||
return fmt.Sprintf("Pulse Pro download broker returned status %d: %s", e.StatusCode, e.detail)
|
||||
}
|
||||
|
||||
// proUpdateTarget maps the running architecture to the broker's artifact
|
||||
// target naming (same mapping the community release assets use).
|
||||
func proUpdateTarget() string {
|
||||
|
|
@ -274,7 +286,8 @@ func (m *Manager) fetchProDownloadManifest(ctx context.Context, creds ProUpdateC
|
|||
if resp.StatusCode != http.StatusOK {
|
||||
detail := ""
|
||||
var brokerErr proBrokerErrorResponse
|
||||
if json.Unmarshal(body, &brokerErr) == nil && brokerErr.Error.Message != "" {
|
||||
decoded := json.Unmarshal(body, &brokerErr) == nil
|
||||
if decoded && brokerErr.Error.Message != "" {
|
||||
detail = brokerErr.Error.Message
|
||||
if brokerErr.Error.Code != "" {
|
||||
detail = brokerErr.Error.Code + ": " + detail
|
||||
|
|
@ -286,7 +299,11 @@ func (m *Manager) fetchProDownloadManifest(ctx context.Context, creds ProUpdateC
|
|||
if detail == "" {
|
||||
detail = resp.Status
|
||||
}
|
||||
return nil, fmt.Errorf("Pulse Pro download broker returned status %d: %s", resp.StatusCode, detail)
|
||||
statusErr := &proBrokerStatusError{StatusCode: resp.StatusCode, detail: detail}
|
||||
if decoded {
|
||||
statusErr.Code = brokerErr.Error.Code
|
||||
}
|
||||
return nil, statusErr
|
||||
}
|
||||
|
||||
var manifest proBrokerResponse
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue