Documentation
¶
Overview ¶
Package denju lets a long-running Go program replace its own binary and hand over to the new version, safely, on Unix and Windows.
It owns the mechanics that are easy to get subtly wrong: a write-ahead journal so an interrupted update can always be resolved, an atomic swap during which the binary is never absent, exec-in-place on Unix so no supervisor ever sees a restart, a detached helper plus service-control-manager restart on Windows, automatic rollback when the new version does not prove itself, and startup repair with a crash-loop guard.
It owns none of the policy. denju never learns how an update was announced or how its outcome is reported: no HTTP, no gRPC, no TLS, no certificates, no protobuf, no authentication. The caller supplies the bytes through a Source, decides what "healthy" means, and reports the outcome wherever it likes. denju also never terminates the process on its own account - exit codes are returned to the caller.
The two gates ¶
An update passes two independent checks, and understanding the difference between them is most of understanding this package.
The first is the selftest, and it runs BEFORE anything is swapped. denju executes the freshly downloaded binary as a child process with the selftest environment variable set; that child runs Config.Selftest and must exit zero. The running program is completely untouched, so a binary that is the wrong architecture, truncated, or unrunnable costs nothing but a log line and a deleted temp file. This is the cheapest guard against the worst outcome: an unstartable binary installed over a working one on a machine nobody can reach.
The second is attestation, and it runs AFTER the swap, in the new image. The new version is running for real; the caller decides it works and calls Updater.Commit, or decides it does not and calls Updater.Rollback. If neither happens before the deadline armed by Updater.Attest, denju restores the previous binary and restarts into it. What counts as proof is the caller's to define - registering with a control plane, passing a health probe, serving a request - because only the caller knows.
Wiring ¶
Three calls have to be in the right place in main. Everything else is event-driven.
u, err := denju.New(denju.Config{
Namespace: "myapp",
Version: buildVersion,
Selftest: func() error { return config.Load() },
Log: denju.SlogLogger(slog.Default()),
})
if err != nil {
return err
}
// 1. First thing in main. This process may be an update helper or a
// selftest child rather than a normal start.
if code, isRole := u.RunProcessRole(); isRole {
os.Exit(code)
}
// 2. Before acquiring any lock or binding any port: a rollback restart
// hands off to a successor that needs them.
if err := u.Repair(); err != nil {
return err
}
// 3. Once serving, arm the deadline for an update that is mid-flight.
u.Attest(ctx, 5*time.Minute)
Then trigger an update whenever the caller learns of one:
res := u.Update(ctx, denju.Request{
ID: "cmd-42",
TargetVersion: "1.4.0",
SHA256: digest,
TargetOS: "linux",
TargetArch: "amd64",
}, src)
Updater.Update does not return when it succeeds: on Unix the process image has been replaced, and on Windows the process has exited so a helper can take over. A returned Result therefore always describes an update that did not happen.
Not all of those are survivable, and Result.Status cannot tell them apart - a refused download and a failed exec whose rollback ALSO failed are both StatusFailed. Result.ProgramIntact is what separates them:
res := u.Update(ctx, req, src)
if !res.ProgramIntact {
// The drain has run, BeforeHandoff has fired, and the binary on disk is
// no longer this process. Carrying on means running a program that has
// already shut down.
log.Error("the update could neither be completed nor undone", "err", res.Error)
os.Exit(1)
}
log.Warn("update refused", "err", res.Error)
It is rare - it takes an exec failure and a failed restore of the previous binary in the same attempt - and it is the one outcome that must not be logged and shrugged off. The journal is deliberately left on disk so the next start can reconcile what is actually there.
Integrity ¶
denju verifies the SHA-256 that the caller passes in Request.SHA256 against the bytes it writes to disk, and verifies that Request.TargetOS and Request.TargetArch match the running process. That is all it verifies. It does not check signatures, does not validate code signing, and places no trust in any transport. Establishing that a digest is authentic is the caller's job. See SECURITY.md.
Resuming, retaining and holding back ¶
A ResumableSource keeps an interrupted download for the next Updater.Update of the same request, which resumes from Request.Offset. Config.RetainPrevious keeps the binary a committed update replaced; Updater.PreviousBinary reports it and FileSource reinstalls it without a network. Config.CooldownScope set to CooldownRolledBackVersion holds back only the version most recently rolled back. Result.Cause carries a failure kind for errors.Is; a digest mismatch after resuming is ErrResumedChecksumMismatch, not ErrChecksumMismatch, and the rule for it is to retry once from zero. Use one Updater per binary.
Compatibility ¶
Config.Namespace determines the name of every file denju writes beside the binary and every environment variable it sets. Those names are part of the on-disk contract between one version of a program and the next, so changing the namespace of a deployed program orphans any update that is in flight during the changeover. Choose it once.
Index ¶
- Variables
- func ResolveBinary() (string, error)
- type Config
- type CooldownScope
- type Level
- type Logger
- type Outcome
- type Pending
- type Request
- type Result
- type ResumableSource
- type Source
- type Status
- type Updater
- func (u *Updater) Attest(ctx context.Context, d time.Duration)
- func (u *Updater) BinaryPath() string
- func (u *Updater) CleanupReported()
- func (u *Updater) Commit() error
- func (u *Updater) InCooldown(now time.Time) (bool, time.Duration, error)
- func (u *Updater) InCooldownFor(targetVersion string, now time.Time) (bool, time.Duration, error)
- func (u *Updater) MarkReported() error
- func (u *Updater) PendingAttestation() (Pending, bool)
- func (u *Updater) PendingOutcome() (*Outcome, error)
- func (u *Updater) PreviousBinary() (path, sha256, version string, ok bool)
- func (u *Updater) Repair() error
- func (u *Updater) Restart() error
- func (u *Updater) Rollback(cause string) error
- func (u *Updater) RunProcessRole() (exitCode int, isRole bool)
- func (u *Updater) Update(ctx context.Context, req Request, src Source) Result
Constants ¶
This section is empty.
Variables ¶
var ( // ErrDownloadFailed means the transfer failed: [Source.Fetch] returned an // error, or overran [Config.DownloadTimeout]. With a [ResumableSource] the // bytes already received are kept, and the next Update for the same request // resumes from them; with any other Source nothing is kept. // // A failure to WRITE what arrived - a full disk, a file-size limit - is not // a transfer failure and does not carry this kind, or any other: the staged // bytes are deleted whatever the source, and a retry would fail at the same // point until the disk has room. ErrDownloadFailed = errors.New("denju: the download failed") // ErrChecksumMismatch means a complete download that started from byte zero // did not hash to [Request.SHA256], so the artifact is not the one promised. // Everything written for it has been deleted. There is no partial-trust // path. A download that resumed fails with [ErrResumedChecksumMismatch] // instead. ErrChecksumMismatch = errors.New("denju: the download does not match its checksum") // ErrResumedChecksumMismatch means a download that RESUMED from bytes an // earlier attempt left on disk did not hash to [Request.SHA256]. The partial // has been deleted, so the next attempt starts from byte zero. // // It is not [ErrChecksumMismatch], and errors.Is does not match the two, // because it does not show that the artifact is wrong. The kept bytes may // have been damaged on the device - a power cut during the earlier attempt - // or the source may not have honoured [Request.Offset]. Retry once from zero: // a mismatch on that attempt is ErrChecksumMismatch, and permanent. ErrResumedChecksumMismatch = errors.New("denju: the resumed download does not match its checksum") // ErrResumeRejected is returned BY a [ResumableSource], wrapped, when it // cannot continue from [Request.Offset]: the artifact behind the request is // shorter than the offset, or is no longer the artifact the earlier bytes // came from. denju then deletes the partial and fails the attempt with this // kind; the next attempt starts from byte zero. denju never restarts a // transfer on its own inside one attempt. ErrResumeRejected = errors.New("denju: the source cannot resume from this offset") )
Failure kinds a caller may need to tell apart. Match them with errors.Is against Result.Cause. The human-readable text is unaffected and is still in Result.Error.
Where a kind below says the bytes on disk have been deleted and the delete itself fails, that failure is logged at ERROR and the error text says the partial download could not be deleted.
Functions ¶
func ResolveBinary ¶
ResolveBinary returns the absolute path of the running executable with symlinks resolved, so an update targets the real file rather than a link to it.
Types ¶
type Config ¶
type Config struct {
// Namespace prefixes every file denju writes beside the binary and every
// environment variable it sets. Lowercase letters, digits and dashes; it
// must start with a letter or digit.
//
// It is part of the on-disk contract between consecutive versions of a
// program: the version being replaced writes the journal that the version
// replacing it has to read. Changing the namespace of a deployed program
// therefore orphans any update in flight during the changeover - the
// successor finds no journal, never commits, never reports, and leaves the
// rollback copy behind for good. Choose it once and leave it alone.
Namespace string
// Version is the running build's version string. It is recorded in the
// journal and is how startup repair tells the new image from the old one.
//
// Two different builds may legitimately carry the same version string; denju
// falls back to comparing SHA-256 digests when they do.
Version string
// BinaryPath is the executable to replace. Defaults to [ResolveBinary],
// which is os.Executable with symlinks resolved.
BinaryPath string
// RecordPath is where the durable outcome record lives. Defaults to
// <binary>.<namespace>-record.json.
//
// Point it at a state directory if the program has one. The record has to
// outlive the journal - it is the cooldown's only anchor and the only memory
// of an outcome that still has to be reported - so it must not sit anywhere
// that gets cleaned up between runs.
RecordPath string
// Log receives everything denju has to say. nil means silence.
//
// It must be safe for concurrent use: denju logs from the attestation
// deadline goroutine and from an abandoned Drain as well as from the
// caller's own goroutine. [SlogLogger] already is; a closure appending to a
// bare slice is not.
Log Logger
// Selftest runs in the freshly downloaded binary, as a child process, before
// anything is swapped. Returning an error rejects the update while the
// running program is still completely intact.
//
// It must be cheap and side-effect free: the current version is still
// running and still owns whatever the program owns. Do not bind a port, take
// a lock, open the program's database, or write to its state directory.
// Loading and validating configuration is the canonical implementation.
//
// nil means the child only has to start and reach [Updater.RunProcessRole]
// without crashing, which is still a real test of the binary.
Selftest func() error
// Drain is called once denju has committed to the update, to let the program
// quiesce - finish or abandon in-flight work, close connections, release
// what the successor will need.
//
// An error aborts the update. A timeout does NOT: a program wedged in its
// own drain must still be able to receive a fix, and the alternative is a
// host pinned on a broken version until a human intervenes.
//
// That has a consequence worth designing for. A callback that overruns
// DrainTimeout is abandoned, not stopped - it keeps running, and from that
// moment it is CONCURRENT with the swap, with BeforeHandoff, and with the
// handover itself. So it has to be safe to run alongside them: treat ctx
// being cancelled as the signal to stop touching shared state, and do not
// assume the program still owns the binary that is on disk.
Drain func(context.Context) error
// BeforeHandoff is called after the binary has been swapped, immediately
// before the process execs into the new image or exits for a helper to take
// over. It cannot abort anything - the point of no return is behind it.
//
// It exists for the announcement a successor cannot make for itself: telling
// a control plane that this instance is going away on purpose, so its slot
// is released before the successor claims one. Keep it fast; the update is
// waiting on it.
BeforeHandoff func()
// ServiceName is the Windows service to restart through the service control
// manager. Empty means denju resolves it itself, which is almost always what
// you want; set it only when the program already knows.
//
// Ignored on Unix, where a restart is an exec in the same process.
ServiceName string
// Cooldown is how long denju refuses a new update after one completes.
// Zero, the default, disables it.
//
// Enable it when a control plane can command a downgrade or can re-issue an
// update to a program that just rolled one back. Without it those two
// situations are unbounded loops: update, roll back, be told to update
// again, forever, re-downloading every cycle. Thirty minutes is a sensible
// starting value.
//
// [Config.CooldownScope] decides what is held: every update (the default) or
// only the version most recently rolled back.
Cooldown time.Duration
// CooldownScope is what Cooldown holds back. The zero value,
// [CooldownAnyUpdate], is the behaviour Cooldown has always had.
// [CooldownRolledBackVersion] requires a positive Cooldown.
CooldownScope CooldownScope
// RetainPrevious keeps the binary an update replaced once that update is
// committed, as <binary>.<namespace>-previous, instead of deleting it.
// [Updater.PreviousBinary] reports it and [FileSource] installs it, so moving
// back to the version the program just left reads nothing from any network.
//
// One generation is kept: the next committed update replaces it. A rollback
// does not touch it - the program is back on the binary the update replaced,
// and the retained one is still the one before that. It costs one binary's
// worth of disk for good. The rollback copy is a hardlink on Unix and costs
// nothing extra, so the peak while an update is in flight is the running
// binary, the download in progress and the retained one, plus a full copy of
// the binary for the detached helper on Windows.
//
// Never execute the retained file in place. On Windows a running .exe cannot
// be replaced, so the next commit could not retain over it.
//
// Turning it off later does not remove a binary already retained, and
// [Updater.PreviousBinary] still reports it.
RetainPrevious bool
// DrainTimeout bounds Drain. Default 2 minutes.
DrainTimeout time.Duration
// SelftestTimeout bounds the selftest child. Default 30 seconds.
SelftestTimeout time.Duration
// HandshakeTimeout bounds the wait for the Windows helper to prove it
// started. Default 10 seconds.
HandshakeTimeout time.Duration
// DownloadTimeout bounds [Source.Fetch]. Default 15 minutes.
//
// It is deliberately independent of the context passed to [Updater.Update]:
// a download must not be cancelled by the same shutdown signal that the
// update is racing.
DownloadTimeout time.Duration
// CrashTolerance is how many times a new image may reach startup repair
// still uncommitted before it is rolled back as a crash loop. Default 2,
// meaning the third such start rolls back.
CrashTolerance int
// StaleThreshold is how old an unresolvable journal must be before startup
// repair treats it as garbage. Default 1 hour.
StaleThreshold time.Duration
// PartialRetention bounds how long an interrupted download is kept for a
// retry to resume. A partial nothing has written to for longer is deleted,
// by the next download or by [Updater.Repair] on a start with no update in
// flight. Default 24 hours.
//
// Only a [ResumableSource] ever leaves a partial behind.
PartialRetention time.Duration
}
Config describes how denju should update one particular program.
Only Namespace and Version are required. Every timeout has a default that matches long production use; override them only with a reason.
type CooldownScope ¶ added in v0.4.0
type CooldownScope int
CooldownScope selects what Config.Cooldown holds back.
const ( // CooldownAnyUpdate refuses every update for Cooldown after one completes - // succeeded, failed or rolled back. The default, and the only behaviour // before scopes existed. It also stops a control plane flip-flopping a // program between two versions that both work. CooldownAnyUpdate CooldownScope = iota // CooldownRolledBackVersion refuses only the version most recently rolled // back, for Cooldown after that rollback, and admits every other target at // once. It closes the update-rollback-update loop without also blocking the // corrective update that should follow a bad one, or a move back to the // version the program just left. // // Versions are matched by string. // // Under this scope every write to the outcome record reads the previous // record first, so that a record that cannot be parsed never silently drops // the hold. The consequence of a corrupt record is severe, and is accepted // because only damage to the disk produces one: every write fails, so every // later update is refused - the corrective one included - and nothing can be // recorded or reported. A [Updater.Commit] is refused as well, which leaves the // update undecided: the new version keeps running, but every start of it // counts against [Config.CrashTolerance], the one that attested included, and // on start CrashTolerance+1 startup repair rolls back a version that was // healthy. Nothing remote can recover the host; the record has to be removed // or repaired on it. // // The hold needs denju v0.4.0 or later with this scope on BOTH images of an // update: the image rolled back FROM normally writes it, and the image rolled // back TO enforces it and carries it forward. See the README. // // It needs a positive Cooldown: [New] rejects it with a zero or negative one. CooldownRolledBackVersion )
type Logger ¶
Logger receives every line denju emits. attrs are alternating key/value pairs, in the same variadic form log/slog uses, so an adapter is usually a one-liner.
A nil Logger is valid and means silence. denju does not fall back to slog.Default(): a library that writes into a program's global logger without being asked is a nuisance, and self-update lines are noisy by nature. Pass SlogLogger to opt in.
type Outcome ¶
type Outcome struct {
// At is when this attempt finished - completed or refused alike.
At time.Time `json:"at"`
// ID echoes [Request.ID], so a report can be tied back to what asked for it.
ID string `json:"command_id"`
// FromVersion and ToVersion describe the move that was attempted.
FromVersion string `json:"from_version"`
ToVersion string `json:"to_version"`
// Status is how it ended; Error carries the cause when it did not succeed.
Status Status `json:"status"`
Error string `json:"error,omitempty"`
// Reported is set once the caller has confirmed delivery. The record itself
// stays on disk afterwards, because the cooldown still needs its timestamp.
Reported bool `json:"reported"`
// CooldownAt is when the last attempt that actually COMPLETED finished -
// succeeded, failed or rolled back. The cooldown measures from here, and a
// refusal carries it forward untouched. Zero means no update has ever
// completed, so nothing is being held back.
//
// Maintained by the store; do not set it by hand.
CooldownAt time.Time `json:"cooldown_at"`
// RolledBackVersion and RolledBackAt name the version most recently rolled
// back and when. They are the anchor [CooldownRolledBackVersion] measures
// from, and every later record carries them forward until another rollback
// replaces them. Empty under any other scope.
//
// A rollback that fails after it was recorded is rewritten as a failure, and
// that rewrite inherits the anchor like any other write: the version stays
// held although the machine is still running it. That is harmless - an
// update to the version already running is refused before the cooldown is
// consulted.
//
// Maintained by the store; do not set them by hand.
RolledBackVersion string `json:"rolled_back_version,omitempty"`
RolledBackAt time.Time `json:"rolled_back_at,omitzero"`
}
Outcome is the durable memory of the last update attempt.
It exists because a successful update destroys the process that performed it: there is no return value to inspect and no error to bubble up. The outcome is written by one process and read back by its successor, which reports it onward and then calls Updater.MarkReported.
It carries two independent things, and keeping them apart is the whole point of the shape. At and the fields describing the attempt are the LAST ATTEMPT, which is what gets reported - and a refused attempt is an attempt, so it overwrites them. CooldownAt is the anchor the cooldown measures from, which only a COMPLETED attempt may move.
type Pending ¶ added in v0.2.0
type Pending struct {
// ID echoes [Request.ID], so a verdict can be tied back to what asked for
// the update.
ID string
// FromVersion and ToVersion describe the move that was made.
FromVersion string
ToVersion string
}
Pending describes an update that has been installed but not yet judged: this process is running the new version, and nothing has committed or rolled it back. Returned by Updater.PendingAttestation.
type Request ¶
type Request struct {
// ID is an opaque identifier echoed back in the [Outcome], so a report can
// be tied to whatever asked for the update. It may be empty, in which case
// no outcome is recorded: with nothing to tie a report to there is nothing
// to report, and no cooldown to hold.
ID string
// TargetVersion is the version being moved to. It is compared against
// [Config.Version] to refuse a pointless update, and recorded in the journal
// so startup repair can tell the new image from the old one.
//
// A version lower than the current one is not an error. Deliberately moving
// a program backwards is a first-class operation.
TargetVersion string
// SHA256 is the hex-encoded SHA-256 of the binary the [Source] will produce.
// denju hashes what it writes and refuses anything that does not match.
//
// This is the ONLY integrity check denju performs. It proves the bytes
// arrived intact; it proves nothing about where they came from. Establishing
// that this digest is authentic is the caller's responsibility.
SHA256 string
// TargetOS and TargetArch, when set, must equal runtime.GOOS and
// runtime.GOARCH or the update is refused. Use the Go spellings: "linux",
// "windows", "darwin", "amd64", "arm64".
//
// Leaving them empty skips the check. Setting them is strongly recommended:
// installing a binary for the wrong platform is not recoverable in place,
// and it is a mistake a control plane makes exactly once, on every host at
// the same time.
TargetOS string
TargetArch string
// Offset is how many bytes of the artifact denju already holds on disk
// from an earlier attempt at this same request. denju sets it; a caller
// must leave it zero, and [Updater.Update] refuses a request that arrives
// with it set.
//
// Only a [ResumableSource] ever sees a non-zero Offset. Any other Source
// always sees zero.
Offset int64
}
Request describes one update to apply.
denju does not care where it came from. Fill it in from a control-plane message, a manifest, a release feed, a command-line flag - whatever the program already trusts.
type Result ¶
type Result struct {
Status Status
Error string
// Cause is the error behind Error, for errors.Is. It is nil exactly when
// Error is empty. A failed transfer or a failed digest carries a kind -
// [ErrDownloadFailed], [ErrResumeRejected], [ErrResumedChecksumMismatch] or
// [ErrChecksumMismatch] - so a caller can tell a transfer worth retrying from
// one that is not. Every other failure carries its message and no kind,
// including the local ones around a download: creating, writing, flushing or
// closing the staged file, and reading back a partial.
Cause error
// ProgramIntact reports whether the program may safely carry on running.
//
// True is the ordinary case and covers every refusal: the update was
// declined or abandoned while the program was completely untouched, and
// there is nothing to do but log it and continue.
//
// False means the update went past the point of no return and could not be
// undone. The drain has run, [Config.BeforeHandoff] has fired, and the
// binary on disk is no longer the image this process is running. Whatever
// the program shut down in preparation for the handover is still shut down,
// and no handover happened. A caller that carries on past a false is running
// a program whose shutdown has already taken place - for anything with
// resources to release, a licence to enforce or a lease to hold, the only
// correct response is to terminate and let a supervisor start the binary
// that is actually on disk.
//
// Status alone cannot express this. A pre-download refusal and a failed
// restore after a failed exec are both StatusFailed, and only one of them is
// survivable.
//
// The zero value is false on purpose: a Result nobody filled in must not
// read as "everything is fine".
ProgramIntact bool
}
Result is what Updater.Update concluded WHEN IT RETURNS.
A successful update never returns - the process image is replaced on Unix, or the process exits so a helper can take over on Windows - so a returned Result always describes an update that did not happen. The program is still running the version it started with.
type ResumableSource ¶ added in v0.4.0
type ResumableSource interface {
Source
// ResumesFromOffset declares that Fetch honours Request.Offset. denju never
// calls it; implementing it is the declaration.
ResumesFromOffset()
}
ResumableSource is a Source that honours Request.Offset: its Fetch writes the artifact starting at that byte, not at the beginning, or returns an error wrapping ErrResumeRejected when it cannot.
Implementing it changes what denju does with a failed download. For a plain Source a failed Fetch deletes everything written. For a ResumableSource a broken transfer keeps the bytes in a partial named after the request - its ID, TargetVersion and SHA256 - and the next Update with the same three resumes from them, after denju re-hashes what is already on disk. The digest check at the end still covers every byte, old and new.
A partial is kept only when the transfer fails. It is deleted when writing to it fails (a full disk), when the source rejects its offset, when the complete file fails the digest, when flushing or closing it after a successful Fetch fails, when a download for a different request starts, and when nothing has written to it for Config.PartialRetention. A download through a plain (non-resumable) Source deletes every partial, the same request's included.
Use one Updater per binary path, across processes as well as within one: two Updaters downloading beside the same binary would each delete the other's partial as belonging to a different request.
func FileSource ¶ added in v0.4.0
func FileSource(path string) ResumableSource
FileSource returns a ResumableSource that reads the artifact from a file on the local filesystem.
It exists for installing the binary kept by Config.RetainPrevious: pass it the path Updater.PreviousBinary reports, and moving back to that version reads no byte from any network. denju verifies what it reads against Request.SHA256 exactly as for any other source, so pointing it at the wrong file fails the digest check rather than installing anything.
An offset past the end of the file is rejected with ErrResumeRejected; an offset exactly at the end writes nothing and succeeds. Anything but a regular file is refused: a device such as /dev/zero would never end.
type Source ¶
type Source interface {
// Fetch writes the new binary to w, which is never nil.
//
// denju owns everything around it: creating the temp file in the target
// binary's directory so the later rename is atomic, hashing what is written
// against [Request.SHA256], making it executable, and deleting it on any
// failure - except that a failed transfer from a [ResumableSource] keeps
// what it wrote, for the next attempt to resume. An implementation only has
// to produce bytes or an error, and should return a write error from w
// rather than swallow it.
//
// Fetch may be slow; it is bounded by [Config.DownloadTimeout] and by the
// context passed to [Updater.Update].
Fetch(ctx context.Context, req Request, w io.Writer) error
}
Source produces the bytes of a new binary.
This is denju's entire view of the outside world. Implement it over HTTP, a gRPC stream, a shared filesystem, an object store, a serial link - denju does not know and does not care. The github.com/qunulabs/denju/httpsource subpackage implements it for a plain HTTPS GET.
type Status ¶
type Status string
Status is how an update attempt ended.
The values are the strings persisted in the outcome record, so they are part of the on-disk contract and must not be renamed.
const ( // StatusSucceeded means the new version was installed and attested healthy. StatusSucceeded Status = "succeeded" // StatusFailed means the update did not happen. The program is still running // the version it was running before, untouched. StatusFailed Status = "failed" // StatusRolledBack means the new version was installed, failed to prove // itself, and the previous version was restored. StatusRolledBack Status = "rolled_back" // StatusRefusedCooldown means the update was declined because another one // completed too recently. See [Config.Cooldown]. StatusRefusedCooldown Status = "refused_cooldown" )
Update outcomes.
type Updater ¶
type Updater struct {
// contains filtered or unexported fields
}
Updater performs self-updates for one program.
It is safe to build early - New touches no files - and it must be built before anything else in main, because the process may turn out to be an update helper or a selftest child rather than a normal start. See Updater.RunProcessRole.
func New ¶
New validates cfg and prepares an Updater. It performs no I/O beyond resolving the running executable's path.
func (*Updater) Attest ¶
Attest arms a deadline for the update this process is the result of. If neither Updater.Commit nor Updater.Rollback is called within d, denju restores the previous binary and restarts into it.
Call it once the program is up but before it is trusted - typically at the end of startup. It returns immediately; the deadline runs in the background. It does nothing at all when no update is in flight, which is the overwhelmingly common case, so it is safe to call unconditionally on every start.
Cancelling ctx disarms the deadline WITHOUT deciding the update. That is deliberate: a program shutting down mid-window has not failed, and rolling it back on the way out would replace a working binary during an operation nobody is watching. The journal survives, and the next start resumes the window.
What counts as proof is the caller's to define, because only the caller knows. Registering with a control plane, passing a health probe, completing one real request - any of these is a better signal than "the process is still alive".
func (*Updater) BinaryPath ¶
BinaryPath returns the executable denju will replace.
func (*Updater) CleanupReported ¶
func (u *Updater) CleanupReported()
CleanupReported removes the update journal once its outcome has been delivered. The outcome record survives - it is what the cooldown reads.
Call it after Updater.MarkReported.
Under Config.RetainPrevious it first finishes a retention the commit could not, and keeps the journal while that still fails. It is not called again for an outcome already reported, so Updater.Repair finishes the retention on a later start and removes the journal then.
func (*Updater) Commit ¶
Commit accepts the update this process is the result of. The rollback copy of the previous binary is released and the outcome is recorded as succeeded, ready for Updater.PendingOutcome to report.
It is a no-op when no update is in flight, so a caller can wire it to a "we're healthy" signal that also fires on ordinary starts.
func (*Updater) InCooldown ¶
InCooldown reports whether the cooldown is holding anything back right now, and how long remains. Under CooldownAnyUpdate that means every update would be refused; under CooldownRolledBackVersion it means only that some version is held, while every other target is admitted - use Updater.InCooldownFor to ask about a particular one. It is always false when Config.Cooldown is zero.
Updater.Update checks this itself; the method is exported so a caller can answer for itself without starting an update.
func (*Updater) InCooldownFor ¶ added in v0.4.0
InCooldownFor reports whether the cooldown would refuse an update to targetVersion right now, and how long remains. Under CooldownAnyUpdate the target makes no difference. It answers for the cooldown only: Updater.Update refuses other things first, such as a target equal to Config.Version.
func (*Updater) MarkReported ¶
MarkReported records that the pending outcome reached its destination, so it is not reported again. The record itself stays on disk: the cooldown still needs its timestamp.
func (*Updater) PendingAttestation ¶ added in v0.2.0
PendingAttestation reports whether an update is waiting for a verdict from THIS process, and what it was.
Updater.Attest is the usual way to resolve one and needs no such check: it arms a deadline and rolls back if nothing decides in time, doing nothing at all when no update is in flight. This is for a caller that would rather judge health its own way — a registration accepted, a probe that has to pass several times, a real request served end to end — and so needs to know whether to start that work at all.
The distinction matters because health checks are rarely free. Running one on every ordinary start, just in case this start happens to follow an update, is both wasteful and a surprise to whoever wrote the check.
It is a query and changes nothing. False means there is no journal, the update has already been decided, or this process is the OLD version rather than the new one — an old image reaching here has already been handled by Updater.Repair, and letting it attest would confirm an update that never took effect.
func (*Updater) PendingOutcome ¶
PendingOutcome returns an update outcome that has not yet been reported, or nil when there is none.
Call it once the program can talk to whatever it reports to - typically right after connecting. The outcome usually describes an update performed by a PREVIOUS process, which had no way to report it because it ceased to exist. Call Updater.MarkReported once delivery is confirmed.
func (*Updater) PreviousBinary ¶ added in v0.4.0
PreviousBinary reports the binary kept by Config.RetainPrevious: its path, its hex SHA-256 and the version it was. ok is false when nothing is retained.
Compare sha256 with an update's digest; when they match, install it with FileSource and no byte crosses a network. The digest is re-checked during the install like any other download, so a retained file that has changed on disk is refused rather than installed.
It has no error to return, so a record that cannot be read, or a record whose binary is gone, is logged at ERROR and reported as nothing retained - never as something retained.
func (*Updater) Repair ¶
Repair resolves in-flight update FILE STATE at startup, before the program does anything else. It reads the journal and reconciles the on-disk binary; it never touches the network.
It may restart the process as the previous binary - a crash-loop rollback - in which case it never returns. Otherwise it returns once the file state is consistent and normal startup may proceed.
Call it BEFORE acquiring a single-instance lock or binding a port: a rollback restart hands off to a successor that needs both. A returned error means the on-disk state could not be made sense of, which is worth failing startup over - continuing would risk resolving it wrongly later.
Outcomes concluded here are precisely the ones no running process could have recorded, because the process that started the update is gone: it crashed, or it exited to let a helper take over. Recording them is not bookkeeping - the record is the cooldown's only anchor and the only input a report has - so without it a version that crashes on startup is rolled back and then immediately reapplied, forever.
func (*Updater) Restart ¶
Restart restarts the program as the binary it is already running, without changing that binary.
On Unix it is exec-in-place: the same PID, argv and env replayed verbatim, never returning on success, with no supervisor involved. On Windows - which has no exec - it hands off to a detached helper that waits for this process to exit and then brings the program back, through the service control manager when it runs as a service. It returns only on failure.
Call it for a caller-requested restart. An update restarts on its own.
func (*Updater) Rollback ¶
Rollback rejects the update this process is the result of: the previous binary is restored and the process restarts into it. It does not return on success.
Rolling back is the right answer even when the cause is ambiguous - an unreachable control plane, a dependency that is down. The previous version is known to have worked and this one has not been shown to, and guessing which of the two a transient failure implicates would be exactly that, a guess. Enable Config.Cooldown - with CooldownRolledBackVersion to hold only this version - so a rejected version is not immediately reapplied.
It is a no-op when no update is in flight.
func (*Updater) RunProcessRole ¶
RunProcessRole reports whether this process was started to play a part in an update rather than to run the program, and if so plays it.
It MUST be the first thing main does, before flags are parsed, before configuration is read, before anything is locked or bound. Two kinds of process reach it: the detached helper that performs a Windows swap, and the selftest child that proves a freshly downloaded binary can start. Neither may do any of the program's normal work.
When it returns true the process has finished its role and must exit with the returned code. denju does not exit on its own:
if code, isRole := u.RunProcessRole(); isRole {
os.Exit(code)
}
func (*Updater) Update ¶
Update downloads, verifies and installs the binary described by req, then hands over to it.
It does not return when it succeeds. On Unix the process image is replaced in place - same PID, same argv, same environment - so no supervisor observes a restart. On Windows the process exits so a detached helper can perform the swap and start the program again. A returned Result therefore always describes an update that did NOT happen, with the program still running exactly as it was.
The order of what follows is load-bearing. Everything that can fail cheaply happens while the running program is completely untouched: the platform check, the cooldown, the preflight, the download, the digest, and a selftest that actually EXECUTES the new binary. Only once all of that has passed does the program drain, journal and swap. The common failures - wrong platform, corrupt transfer, unrunnable binary, no disk space, no write permission - therefore cost nothing but a log line and a recorded outcome. The one thing a failure may leave is a ResumableSource's partial after a broken transfer, kept on purpose for the retry; a full disk deletes it.
Update is not safe to call concurrently with itself.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
examples
|
|
|
basic
command
Command basic is a complete, runnable sketch of a long-running program that updates itself with denju.
|
Command basic is a complete, runnable sketch of a long-running program that updates itself with denju. |
|
Package httpsource implements denju.Source over plain HTTP.
|
Package httpsource implements denju.Source over plain HTTP. |