Documentation
¶
Overview ¶
Package upgrade implements the in-dashboard upgrade flow.
The flow is split across two trust boundaries.
The running server is unprivileged and network-facing. It reads the public release metadata endpoint for the newest stable release, caches that answer, and decides whether it is newer than the running version. When an operator confirms an upgrade it downloads the release archive for its own platform, verifies it against the SHA-256 published with the release, stages it in a handoff directory, and writes a request file naming the validated version. It never replaces a binary and never restarts anything.
The privileged applier ("oberwatch upgrade apply", started by a systemd path unit when the request file appears) does the rest. It re-parses the requested version, refuses anything that is not strictly newer than the version it is itself built as, fetches the release checksums itself from the pinned release host, re-verifies the staged archive against them, extracts the single "oberwatch" member, checks that the extracted binary runs and reports the requested version, keeps a rollback copy of the binary it is replacing, swaps the install path with an atomic rename, records the outcome, and restarts the service.
Two properties follow from that split, and both are what make the flow safe to expose in a dashboard:
- Nothing the flow reads, writes, fetches or executes is derived from a request. Every URL, path, archive name and command comes from a package constant plus a strictly parsed semantic version. There is no parameter to inject a tag, URL, path or shell fragment into.
- The privileged half does not trust the unprivileged half. It establishes the authenticity of what it installs from the release checksums it fetches itself. A compromised server can at most cause a genuine, newer, published release to be installed.
Configuration and data are outside the flow entirely. The only paths written are the install path, a rollback copy next to it, and the handoff directory.
An installation that cannot apply an upgrade in place — a container, a build that did not come from a release, a platform with no release archive, or an install without the privileged applier — is reported as unsupported with the real fallback instruction for that case, rather than being shown an action that would not work.
Index ¶
- Constants
- Variables
- func ArchiveName(version Version, platform Platform) (string, error)
- func ArchiveURL(baseURL string, version Version, platform Platform) (string, error)
- func ChecksumsURL(baseURL string, version Version) (string, error)
- func Compare(a, b Version) int
- func ExtractBinary(archive io.Reader, destinationPath string) error
- func IsUpgrade(current, latest Version) bool
- func OpenStagedArchive(path string) (*os.File, error)
- func ParseChecksums(raw []byte) (map[string]string, error)
- func ReadRequest(stateDir string) (Request, Version, error)
- func RemoveRequest(stateDir string) error
- func RemoveResult(stateDir string) error
- func RequestPath(stateDir string) string
- func RestartService(ctx context.Context, service string) error
- func ResultPath(stateDir string) string
- func StagedArchivePath(stateDir string, version Version, platform Platform) (string, error)
- func VerifyDigest(expected string, actual string) error
- func WriteRequest(stateDir string, request Request) error
- func WriteResult(stateDir string, result Result) error
- type Applier
- type CheckSnapshot
- type Checker
- type Detector
- type Environment
- type Fetcher
- type Manager
- type Platform
- type Request
- type Result
- type ResultStatus
- type Source
- type Status
- type Version
Constants ¶
const ( // MaxArchiveBytes bounds a release archive download and the size of a // staged archive the applier will read. MaxArchiveBytes = 128 << 20 // MaxChecksumsBytes bounds the checksums document. MaxChecksumsBytes = 64 << 10 )
const ( // InstallerFallback re-runs the installer, which upgrades in place and // keeps the existing config and data. InstallerFallback = "" /* 126-byte string literal not displayed */ // ContainerFallback covers a container, where replacing the binary inside // the running container would be undone by the next recreate. ContainerFallback = "Pull the new image and recreate the container: docker pull ghcr.io/oberwatch/oberwatch:latest" // SourceFallback covers a build that did not come from a release. SourceFallback = "This build did not come from a release. Check out the release tag and rebuild with: make build" // PlatformFallback covers a platform with no release archive. PlatformFallback = "" /* 139-byte string literal not displayed */ )
Fallback instructions. Each one is the real command for the installation it describes, so an operator who cannot use the button is still told exactly how to get the new version.
const ( // CheckTimeout bounds one release metadata read. CheckTimeout = 8 * time.Second // DownloadTimeout bounds one artifact download, including verification of // what was downloaded. DownloadTimeout = 5 * time.Minute )
const ( // LatestReleaseURL is the public release metadata endpoint. It is read // without credentials. LatestReleaseURL = "https://api.github.com/repos/OberWatch/oberwatch/releases/latest" // DownloadBaseURL is the only prefix release artifacts are fetched from. // A tag is appended to it, never substituted into it. DownloadBaseURL = "https://github.com/OberWatch/oberwatch/releases/download/" // ChecksumsName is the release asset that lists the SHA-256 of every // archive in the release. ChecksumsName = "checksums.txt" // BinaryName is the only archive entry the applier will extract. BinaryName = "oberwatch" // StateDir is the handoff directory the installer provisions: writable by // the service user, read by the privileged applier. Its presence is part of // how a supported installation is recognised. StateDir = "/var/lib/oberwatch/upgrade" // RequestName is the file the server writes to ask for an upgrade. RequestName = "request.json" // ResultName is the file the privileged applier writes when it finishes. // The server reads it after the restart to report an honest outcome. ResultName = "result.json" // ApplyUnitPath is the privileged applier unit. The server checks that it // exists before offering an in-dashboard upgrade, so the button is never // shown on an installation where nothing can apply the request. ApplyUnitPath = "/etc/systemd/system/oberwatch-upgrade.service" // ServiceName is the unit restarted after a successful swap. ServiceName = "oberwatch" // BackupName is the file name the replaced binary is kept under, next to // the install path, for rollback. BackupName = "oberwatch.previous" )
Everything the upgrade flow reads, writes or fetches is derived from the constants below plus a strictly parsed Version. No request body, query parameter, header, config value or environment variable ever contributes a URL, tag, path or command. That is deliberate: it is the property that makes "arbitrary command, URL, tag or script execution" impossible rather than merely filtered.
const ( // CheckTTL is how long a successful release check is reused. The check is a // request to a third party, so it is cached rather than repeated per page // load; the served snapshot always carries the time it was taken. CheckTTL = 6 * time.Hour // CheckRetryInterval bounds how often a failed check is retried, so an // outage at the release source cannot be turned into a request loop by // reloading the dashboard. CheckRetryInterval = 15 * time.Minute )
const (
// MaxBinaryBytes bounds the extracted binary.
MaxBinaryBytes = 256 << 20
)
Variables ¶
var ( // ErrNotNewer means the request asked for a version that is not newer than // what is installed. It covers a downgrade and a reinstall of the same // version, both of which the applier refuses. ErrNotNewer = errors.New("requested release is not newer than the installed version") // ErrRefused means the applier declined to install. Nothing was replaced. ErrRefused = errors.New("upgrade refused") )
var ( // ErrBinaryNotInArchive means the archive did not contain the binary. ErrBinaryNotInArchive = errors.New("release archive does not contain the oberwatch binary") // ErrUnsafeArchive means the archive contained an entry that a release // archive never contains, such as an absolute or traversing path. ErrUnsafeArchive = errors.New("release archive contains an unsafe entry") )
var ( // ErrChecksumMismatch means a downloaded or staged artifact did not match // the SHA-256 the release publishes for it. Nothing is installed after it. ErrChecksumMismatch = errors.New("release artifact checksum mismatch") // ErrChecksumMissing means the release checksums document does not list the // artifact at all, so there is nothing to verify it against. ErrChecksumMissing = errors.New("release artifact is not listed in checksums") ErrArtifactUnavailable = errors.New("release artifact unavailable") // ErrArtifactTooLarge means an artifact exceeded its size bound. ErrArtifactTooLarge = errors.New("release artifact is too large") )
var ( // ErrNoUpdate means there is nothing newer to install. ErrNoUpdate = errors.New("no newer stable release is available") // ErrInProgress means an upgrade is already being prepared. ErrInProgress = errors.New("an upgrade is already in progress") )
var ErrInvalidVersion = errors.New("invalid version")
ErrInvalidVersion is returned for strings that are not a strict semantic version.
var ErrNoRequest = errors.New("no upgrade request is waiting")
ErrNoRequest means there is no upgrade request waiting. It is the normal state, not a failure.
ErrReleaseUnavailable means the public release source could not be read, or answered with something that is not a usable stable release.
var ErrUnsupported = errors.New("in-dashboard upgrade is not supported for this installation")
ErrUnsupported means this installation cannot apply an upgrade in place. It is not a failure: it is the condition under which the dashboard shows a fallback instruction instead of a button.
var ErrUnsupportedPlatform = errors.New("unsupported platform")
ErrUnsupportedPlatform is returned for an OS or architecture that has no release archive.
Functions ¶
func ArchiveName ¶
ArchiveName returns the release archive file name for a stable version on the given platform. It refuses prereleases and unsupported platforms so a name is never built from a version whose artifacts do not exist.
func ArchiveURL ¶
ArchiveURL returns the download URL for a release archive.
func ChecksumsURL ¶
ChecksumsURL returns the download URL for a release's checksums document.
func Compare ¶
Compare orders two versions by semantic version precedence. It returns -1 when a is lower than b, 0 when equal and 1 when a is higher.
func ExtractBinary ¶
ExtractBinary writes the "oberwatch" member of an already-verified release archive to destinationPath with mode 0755, and refuses everything else.
It reads from an open handle rather than a path on purpose. The caller verifies the bytes behind that handle against the release checksums; taking a path here instead would mean re-opening the file, and the unprivileged service that owns the handoff directory could swap it in between.
No name from the archive is ever used to open a file: the single member that is extracted is matched by exact name and written to the caller's path. Entry names that a release archive never carries — absolute paths, traversing paths, links, devices — are treated as a reason to stop rather than something to skip, because a verified release has no reason to contain them.
func IsUpgrade ¶
IsUpgrade reports whether latest is a version this installation should be offered as an upgrade from current.
The policy is deliberately narrow, because the button it drives triggers a service restart:
- Only a stable release is ever offered. A prerelease is never an upgrade target, whatever the running version is.
- The candidate must be strictly higher than the running version by semantic version precedence, so an equal or older release offers nothing.
A running prerelease is still allowed to move to the stable release of the same core version, because semver precedence already places v0.1.4 above v0.1.4-rc.1.
func OpenStagedArchive ¶
OpenStagedArchive opens a staged release archive for verification.
The file is opened without following a symlink and checked through the resulting handle rather than by a separate stat of the path, so nothing that can write the handoff directory can substitute a different file between the check and the read.
func ParseChecksums ¶
ParseChecksums reads a "sha256sum" style document into a map of asset name to lowercase hex digest.
The parser is strict on purpose: it is the only thing standing between a malformed or hostile checksums document and an installed binary. A line that is not exactly a 64-character hex digest followed by a single-segment file name is an error, not a skipped line, because silently skipping a line is how an artifact ends up unverified.
func ReadRequest ¶
ReadRequest reads and validates the waiting upgrade request.
It returns ErrNoRequest when there is none. Every other unexpected condition — a symlink, an oversized file, an unknown field, a tag that is not a stable release tag — is an error, because the privileged applier must refuse a request it does not fully understand rather than act on part of it.
func RemoveRequest ¶
RemoveRequest deletes the waiting request. It is not an error when there is none.
func RemoveResult ¶
RemoveResult deletes a recorded outcome, so a new attempt does not show the previous one as its own. It is not an error when there is none.
func RequestPath ¶
RequestPath returns the handoff request file path inside a state directory.
func RestartService ¶
RestartService restarts a systemd unit by name.
systemctl is located at an absolute path rather than through PATH, and the unit name is checked against a narrow pattern. No shell is involved, so there is nothing for a name to be injected into even if one ever came from somewhere other than a constant.
func ResultPath ¶
ResultPath returns the handoff result file path inside a state directory.
func StagedArchivePath ¶
StagedArchivePath returns where a verified archive for a version waits for the privileged applier. Both sides derive it from the same validated version, so the applier never opens a path named by the request file.
func VerifyDigest ¶
VerifyDigest compares an expected and an actual hex SHA-256.
func WriteRequest ¶
WriteRequest writes an upgrade request atomically.
The file is written under a temporary name and renamed into place, so the privileged applier — which is started by the request file appearing — never observes a half-written request.
func WriteResult ¶
WriteResult records an outcome atomically. The file is world-readable because the privileged applier writes it and the unprivileged service reads it.
Types ¶
type Applier ¶
type Applier struct {
// Fetcher downloads the release checksums. It is the applier's own,
// independent of whatever the service used.
Fetcher *Fetcher
// Restart restarts the service after a successful swap. A nil value uses
// systemctl.
Restart func(ctx context.Context) error
// VerifyBinary checks that the extracted binary runs and reports the
// version that was requested. A nil value runs "<binary> version".
VerifyBinary func(ctx context.Context, binaryPath string, target Version) error
// Now is the clock used for the recorded finish time.
Now func() time.Time
// StateDir is the handoff directory.
StateDir string
// InstallPath is the binary to replace. Empty means "the running
// executable", which is the correct answer for the installed applier: it is
// the installed binary.
InstallPath string
// Installed is the version this applier is built as, which is the version
// currently installed.
Installed Version
// Platform is the release platform, used to derive the staged archive name.
Platform Platform
}
Applier is the privileged half of the upgrade flow.
It re-establishes every fact for itself rather than trusting the request: it re-parses the requested version, refuses anything that is not strictly newer than the version it is itself built as, fetches the release checksums from the pinned release host, verifies the staged archive against them, and only then extracts and swaps the binary. A compromised unprivileged service can therefore cause at most the installation of a genuine, newer, published release — never a binary of its choosing.
Configuration and data are never touched: the only paths written are the install path, a backup next to it, and the handoff directory.
func NewApplier ¶
NewApplier builds an Applier for the installed version.
func (*Applier) Apply ¶
Apply installs the waiting upgrade request.
It returns ErrNoRequest when there is nothing to do, which is the normal state: the applier is started whenever the request file appears and must be safe to run at any other time too.
Every outcome, including a refusal, is recorded in the handoff directory before Apply returns, so the dashboard can report what happened after the restart.
type CheckSnapshot ¶
type CheckSnapshot struct {
// CheckedAt is when the released version in this snapshot was read. It is
// zero when no check has completed, which is how a caller tells "no update"
// from "not checked yet".
CheckedAt time.Time
// Latest is the newest stable release, or nil when the last check failed
// and none has ever succeeded.
Latest *Version
// Err describes why the last check failed, and is empty after a success.
Err string
}
CheckSnapshot is the result of the most recent release check.
type Checker ¶
type Checker struct {
// contains filtered or unexported fields
}
Checker keeps a bounded, cached view of the latest release. One check runs at a time, a success is reused for CheckTTL, and a failure is not retried for CheckRetryInterval.
func NewChecker ¶
func NewChecker() *Checker
NewChecker builds a Checker over the public release source.
func (*Checker) EnsureFresh ¶
EnsureFresh starts a background check when the cached view has aged out. It returns immediately: the caller answers from the snapshot it already has, which is why a cold cache reports "not checked yet" instead of blocking a dashboard load on a third-party request.
func (*Checker) Refresh ¶
func (c *Checker) Refresh(ctx context.Context) CheckSnapshot
Refresh returns a current view, running a check when the cached one has aged out. It is used on the upgrade path, where a decision has to be made against a current answer rather than a possibly cold cache.
When a check is already running it waits for that one rather than starting a second or giving up: reporting the release source as unavailable because something else was mid-check would refuse an upgrade for a reason that is not true.
func (*Checker) Snapshot ¶
func (c *Checker) Snapshot() CheckSnapshot
Snapshot returns the current cached view without doing any I/O.
type Detector ¶
type Detector struct {
// StateDir is the handoff directory the server must be able to write.
StateDir string
// ApplyUnitPath is the privileged applier unit. Without it, a request would
// be written and never acted on, so the action must not be offered.
ApplyUnitPath string
// ContainerMarkers are the files whose presence means this process is in a
// container.
ContainerMarkers []string
// Platform is the release platform this binary was built for.
Platform Platform
}
Detector inspects the pieces the installer provisions to decide whether an upgrade can be applied in place.
Every location it looks at is a package constant. The zero value is not usable; build one with NewDetector.
func NewDetector ¶
func NewDetector() *Detector
NewDetector builds a Detector over the installed locations.
func (*Detector) Detect ¶
func (d *Detector) Detect(currentVersion string) Environment
Detect reports whether the running installation can apply an upgrade for the given running version.
The checks run from the most specific fallback to the least, so an operator is told the thing that actually applies to them: a container is told to pull an image, a source build is told to rebuild, and a release install that is simply missing the privileged applier is told to re-run the installer.
type Environment ¶
type Environment struct {
// Supported is true only when every condition for applying an upgrade in
// place is met. It is never assumed: each condition is checked.
Supported bool
// Reason states why an upgrade cannot be applied here. It is empty when
// Supported is true.
Reason string
// Fallback is the instruction shown in place of the upgrade action.
Fallback string
}
Environment reports whether an in-dashboard upgrade can be applied on this installation, and what to do instead when it cannot.
type Fetcher ¶
type Fetcher struct {
// HTTPClient is bounded and follows only a short redirect chain that stays
// on the release hosts. A nil client is replaced by the default one.
HTTPClient *http.Client
// BaseURL is the download prefix. Empty means DownloadBaseURL. It is a
// struct field only so tests can point downloads at a local server;
// nothing in the running system derives it from configuration or from a
// request.
BaseURL string
}
Fetcher downloads release artifacts from the public release host.
func NewFetcher ¶
func NewFetcher() *Fetcher
NewFetcher builds a Fetcher against the public release download host.
func (*Fetcher) Checksums ¶
Checksums downloads and parses the checksums document published with a release.
func (*Fetcher) DownloadArchive ¶
func (f *Fetcher) DownloadArchive(ctx context.Context, version Version, platform Platform, stateDir string) (string, error)
DownloadArchive downloads the release archive for a version onto disk and verifies it against the release checksums before the file is given its final name. It returns the path of the verified archive.
A download that fails verification is deleted, so an unverified archive never remains under the name the privileged applier looks for.
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager answers the dashboard's upgrade questions and prepares an upgrade for the privileged applier.
It is the unprivileged half of the flow: it reads public release metadata, downloads and verifies the release archive for its own platform, and writes a request naming the validated version. It never replaces a binary, restarts a service, or touches configuration or data.
func NewManager ¶
NewManager builds a Manager for the running version. The version string is the one the binary reports; a version that is not a release tag leaves the installation unsupported with an honest reason.
func (*Manager) CurrentVersion ¶
CurrentVersion returns the running version as the binary reports it.
func (*Manager) Environment ¶
func (m *Manager) Environment() Environment
Environment reports the currently known support state.
func (*Manager) Prepare ¶
Prepare downloads and verifies the release archive for the newest stable release and hands it to the privileged applier.
Nothing about the target comes from the caller. The version is the one the release check returned, the archive name and URL are derived from it, and the request file names only that version. Prepare therefore has no parameter a request could influence.
It returns the version that was handed off. The service is restarted onto it by the privileged applier shortly afterwards.
type Platform ¶
Platform is the OS and architecture a release archive is built for.
func CurrentPlatform ¶
func CurrentPlatform() Platform
CurrentPlatform reports the platform this binary was built for.
type Request ¶
type Request struct {
// Tag is the release tag to install, in "vMAJOR.MINOR.PATCH" form.
Tag string `json:"tag"`
// From is the version that was running when the request was written. It is
// recorded for the result and is never used to decide anything.
From string `json:"from"`
// RequestedAt is when the request was written, in RFC3339.
RequestedAt string `json:"requested_at"`
}
Request is the handoff record the unprivileged server writes and the privileged applier reads.
It names a version and nothing else. Every path, URL and command the applier uses is derived from that version and from package constants, so this file cannot point the applier at another host, archive, directory or program even if something manages to write it.
type Result ¶
type Result struct {
// Status is the recorded outcome.
Status ResultStatus `json:"status"`
// Tag is the release that was being installed.
Tag string `json:"tag"`
// From is the version that was replaced.
From string `json:"from"`
// Message explains the outcome, including what to do next after a failure
// or when a restart is still needed.
Message string `json:"message"`
// FinishedAt is when the applier finished, in RFC3339.
FinishedAt string `json:"finished_at"`
}
Result is what the privileged applier leaves behind. The server reads it after the restart, which is how the dashboard reports an outcome that outlives the process that asked for it.
func ReadResult ¶
ReadResult reads the recorded outcome, if any.
The second return value is false when there is no result to report, which includes a result file that does not validate: showing a half-understood outcome in the dashboard would be worse than showing none.
type ResultStatus ¶
type ResultStatus string
ResultStatus is the outcome the privileged applier recorded.
const ( // ResultSucceeded means the new binary is installed and the service was // restarted onto it. ResultSucceeded ResultStatus = "succeeded" // ResultRestartRequired means the new binary is installed but the service // was not restarted, so the old version is still running. ResultRestartRequired ResultStatus = "restart_required" // ResultFailed means nothing was installed. ResultFailed ResultStatus = "failed" )
Result statuses.
func (ResultStatus) Valid ¶
func (s ResultStatus) Valid() bool
Valid reports whether a status is one this package writes.
type Source ¶
type Source struct {
// HTTPClient is bounded and refuses redirects. A nil client is replaced by
// the default one.
HTTPClient *http.Client
// URL is the endpoint read. Empty means LatestReleaseURL. It is a struct
// field only so tests can point the check at a local server; nothing in the
// running system derives it from configuration or from a request.
URL string
}
Source reads the latest published release from the public release metadata endpoint. No credentials are sent and no response field other than the tag and the draft/prerelease flags is used.
func NewSource ¶
func NewSource() *Source
NewSource builds a Source that reads the public release metadata endpoint.
func (*Source) Latest ¶
Latest returns the version of the newest published stable release.
Anything the endpoint answers that is not a published, non-draft, non-prerelease, strictly valid release tag is reported as ErrReleaseUnavailable rather than guessed at, so malformed release metadata can never produce an upgrade offer.
type Status ¶
type Status struct {
// CheckedAt is when the release check that produced LatestVersion ran. It
// is nil until a check has completed, which is what tells the dashboard to
// show "checking" rather than "up to date".
CheckedAt *time.Time
// LastResult is the outcome of the most recent apply, read back from disk
// so it survives the restart the apply causes. It is nil when there is
// none.
LastResult *Result
// CurrentVersion is the running version, as the binary reports it.
CurrentVersion string
// LatestVersion is the newest stable release tag, or empty when no check
// has succeeded.
LatestVersion string
// CheckError describes why the last release check failed, and is empty
// after a success.
CheckError string
// UnsupportedReason states why this installation cannot apply an upgrade.
UnsupportedReason string
// Fallback is what to do instead when Supported is false.
Fallback string
// UpdateAvailable is true only when a newer stable release was actually
// observed. A failed check leaves it false without claiming to be current.
UpdateAvailable bool
// Supported reports whether this installation can apply an upgrade.
Supported bool
// InProgress is true while an upgrade is being prepared.
InProgress bool
}
Status is the whole answer the dashboard needs to decide what to show next to the version. Every field is either observed or explicitly empty; nothing is inferred.
type Version ¶
Version is a parsed semantic version. Build metadata is rejected at parse time because release tags never carry it.
func ParseReleaseTag ¶
ParseReleaseTag parses a release tag, which must carry the "v" prefix that release archives and download URLs are built from.
func ParseVersion ¶
ParseVersion parses "MAJOR.MINOR.PATCH" with an optional leading "v" and an optional "-prerelease" suffix. Numeric identifiers must not have leading zeros and prerelease identifiers may only contain [0-9A-Za-z-].
func (Version) Core ¶
Core returns "MAJOR.MINOR.PATCH" without prefix or prerelease. Release archive names use this form.