Pulse/internal/updates/adapter_installsh.go
rcourtman 9560fe6c07 feat(updates): surface digest-pinned Pro Docker update commands from the broker
A Docker install of the Pro runtime had no sane update path: the binary
self-updater refuses to run in a container, the customer compose file pins
the previous image digest (so `docker compose pull` never updates), and the
in-app guidance showed community `docker pull rcourtman/pulse:<tag>` commands
that silently downgrade the container to the community build - the same
failure mode 93bccaff1 fixed for binary installs.

The update check on the Pro binary already fetches the license server
download broker manifest (GET /v1/downloads/pulse-pro), which carries the
digest-pinned image ref plus ready-to-run compose commands. Relay that block
on UpdateInfo.dockerUpdate for Docker deployments, behind the existing
stable/rc channel guard, failing closed unless both compose commands
reference the digest-pinned ref (never a mutable tag).

Settings -> Updates now renders those commands as copy blocks when an update
is available, and the idle Docker box, the update banner, and the docker
update plan suppress every community-image command whenever the compiled
runtime identity is pro.

Contracts: api-contracts (update transport dockerUpdate block),
deployment-installability (Pro Docker update path), frontend-primitives
(install guide Pro-runtime guidance), with transport and settings-shell
proofs extended accordingly.
2026-07-10 16:03:59 +01:00

146 lines
4 KiB
Go

package updates
import (
"context"
"fmt"
"os"
"strings"
"github.com/rcourtman/pulse-go-rewrite/pkg/edition"
)
// The adapters in this file are plan providers only: they describe how an
// update would be performed for a deployment type (GET /api/updates/plan).
// The actual apply for every deployment runs through the in-Go pipeline in
// manager.go ApplyUpdate.
// InstallShAdapter describes install.sh-based updates for systemd/LXC deployments
type InstallShAdapter struct{}
// NewInstallShAdapter creates a new install.sh adapter
func NewInstallShAdapter() *InstallShAdapter {
return &InstallShAdapter{}
}
// SupportsApply returns true for systemd and proxmoxve deployments
func (a *InstallShAdapter) SupportsApply() bool {
return true
}
// GetDeploymentType returns the deployment type
func (a *InstallShAdapter) GetDeploymentType() string {
return "systemd" // Can be "systemd" or "proxmoxve"
}
// PrepareUpdate returns update plan information
func (a *InstallShAdapter) PrepareUpdate(ctx context.Context, request UpdateRequest) (*UpdatePlan, error) {
plan := &UpdatePlan{
CanAutoUpdate: true,
RequiresRoot: true,
RollbackSupport: true,
EstimatedTime: "2-5 minutes",
Instructions: []string{
fmt.Sprintf("Download and install Pulse %s", request.Version),
"Create backup of current installation",
"Extract and apply update",
"Restart Pulse service",
},
Prerequisites: []string{
"Root access (sudo)",
"Internet connection",
"About 1.2GB free disk space for update staging",
},
}
return plan, nil
}
// DockerUpdater provides instructions for Docker deployments
type DockerUpdater struct{}
const defaultDockerImageRepo = "rcourtman/pulse"
// NewDockerUpdater creates an updater for Docker deployments.
func NewDockerUpdater() *DockerUpdater {
return &DockerUpdater{}
}
func (u *DockerUpdater) SupportsApply() bool {
return false
}
func (u *DockerUpdater) GetDeploymentType() string {
return "docker"
}
func (u *DockerUpdater) PrepareUpdate(ctx context.Context, request UpdateRequest) (*UpdatePlan, error) {
// The compiled Pro binary must never be pointed at the community image:
// pulling rcourtman/pulse silently replaces the container with the
// community build. Its digest-pinned commands come from the license
// server broker on the update-check response instead.
if edition.IsPro() {
return &UpdatePlan{
CanAutoUpdate: false,
Instructions: []string{
fmt.Sprintf("Update to Pulse Pro %s with the digest-pinned Docker commands shown in Settings → Updates (fetched from your license server).", request.Version),
},
RequiresRoot: false,
RollbackSupport: true,
EstimatedTime: "1-2 minutes",
}, nil
}
imageRepo := strings.TrimSpace(os.Getenv("PULSE_DOCKER_IMAGE_REPO"))
if imageRepo == "" {
imageRepo = defaultDockerImageRepo
}
imageRef := fmt.Sprintf("%s:%s", imageRepo, strings.TrimPrefix(request.Version, "v"))
return &UpdatePlan{
CanAutoUpdate: false,
Instructions: []string{
fmt.Sprintf("docker pull %s", imageRef),
"docker stop pulse",
fmt.Sprintf("docker run -d --name pulse %s", imageRef),
},
RequiresRoot: false,
RollbackSupport: true,
EstimatedTime: "1-2 minutes",
}, nil
}
// AURUpdater provides instructions for Arch Linux AUR deployments
type AURUpdater struct{}
// NewAURUpdater creates an updater for Arch Linux AUR deployments.
func NewAURUpdater() *AURUpdater {
return &AURUpdater{}
}
func (u *AURUpdater) SupportsApply() bool {
return false
}
func (u *AURUpdater) GetDeploymentType() string {
return "aur"
}
func (u *AURUpdater) PrepareUpdate(ctx context.Context, request UpdateRequest) (*UpdatePlan, error) {
return &UpdatePlan{
CanAutoUpdate: false,
Instructions: []string{
"yay -Syu pulse-monitoring",
"# or",
"paru -Syu pulse-monitoring",
},
RequiresRoot: false,
RollbackSupport: false,
EstimatedTime: "1-2 minutes",
}, nil
}
// Ensure adapters implement Updater interface
var (
_ Updater = (*InstallShAdapter)(nil)
_ Updater = (*DockerUpdater)(nil)
_ Updater = (*AURUpdater)(nil)
)