denju

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 17, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

README

denju

Self-update and handover for long-running Go programs, on Unix and Windows.

denju 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. You supply the bytes; you decide what "healthy" means; you report the outcome wherever you like. denju also never terminates your process on its own account.

import "github.com/qunulabs/denju"

One dependency (golang.org/x/sys, Windows only). Go 1.24+. Apache-2.0.

The two gates

An update passes two independent checks, and the difference between them is most of what there is to understand.

The selftest runs before anything is swapped. denju executes the freshly downloaded binary as a child process with the selftest environment variable set; that child runs your Selftest function 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.

Attestation runs after the swap, in the new image. The new version is running for real. Your program decides it works and calls Commit, or decides it does not and calls Rollback. If neither happens before the deadline, denju restores the previous binary and restarts into it.

Wiring

Three calls have to be in the right place. Everything else is event-driven.

u, err := denju.New(denju.Config{
    Namespace: "myapp",       // see "The namespace is permanent" below
    Version:   buildVersion,
    Selftest:  func() error { return config.Load() },
    Drain:     func(ctx context.Context) error { return server.Shutdown(ctx) },
    Log:       denju.SlogLogger(slog.Default()),
    Cooldown:  30 * time.Minute,
})
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
}

// ... normal startup ...

// 3. Once serving, arm the deadline for an update that is mid-flight. A no-op
//    when there isn't one, so call it unconditionally.
u.Attest(ctx, 5*time.Minute)

Report anything the previous process could not, once you can talk to whatever you report to:

if out, err := u.PendingOutcome(); err == nil && out != nil {
    if err := controlPlane.Report(out); err == nil {
        _ = u.MarkReported()
        u.CleanupReported()
    }
}

Then trigger an update whenever you learn of one:

res := u.Update(ctx, denju.Request{
    ID:            "cmd-42",
    TargetVersion: "1.4.0",
    SHA256:        digest,
    TargetOS:      "linux",
    TargetArch:    "amd64",
}, httpsource.New(preSignedURL))

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 always describes an update that did not happen.

Not all of those are survivable, and Status cannot tell them apart — a refused download and a failed exec whose rollback also failed are both StatusFailed. 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 takes an exec failure and a failed restore of the previous binary in the same attempt, so it is rare — and it is the one outcome that must not be logged and shrugged off. The journal is left on disk so the next start can reconcile it.

Once healthy, accept it:

if err := u.Commit(); err != nil {
    log.Error("could not commit the update", "err", err)
}

Supplying the bytes

Source is one method. Implement it over whatever you already have.

type Source interface {
    Fetch(ctx context.Context, req Request, w io.Writer) error
}

denju owns everything around it: creating the temp file in the target binary's directory so the rename that follows is atomic, hashing what is written against Request.SHA256, making it executable, and deleting it on any failure (a ResumableSource, below, keeps it after a broken transfer). Return a write error from w rather than swallowing it.

For a plain HTTPS GET, github.com/qunulabs/denju/httpsource is about forty lines and keeps net/http out of the core:

src := httpsource.New(url, httpsource.WithMaxBytes(512<<20))
Resuming an interrupted download

A Source that also implements denju.ResumableSource declares that it honours Request.Offset:

type ResumableSource interface {
    Source
    ResumesFromOffset() // a declaration; denju never calls it
}

For such a source a broken transfer keeps the bytes that arrived, in <binary>.<ns>-download-<key>.partial, where the key is derived from the request's ID, TargetVersion and SHA256. Calling Update again with the same three resumes: denju re-hashes what is on disk, sets Request.Offset, and your Fetch writes only the rest. The digest check still covers every byte.

A broken transfer is the only failure that keeps the partial. It is deleted when writing to it fails (a full disk: keeping it would hold the space that just ran out), when your source returns an error wrapping denju.ErrResumeRejected (it cannot continue from that offset), when the complete file fails the digest, when flushing or finalizing it after a successful Fetch fails, and when a download for a different request starts. Each of those ends the attempt: denju never restarts a transfer by itself inside one Update, so it is the next call that starts from zero. A partial nothing has written to for Config.PartialRetention (default 24 hours) is deleted at the start of the call that finds it, which then proceeds from zero itself. If a delete fails — on Windows, another program holding the file — denju logs it at ERROR and the error says the partial could not be deleted; the next attempt finds it again.

Leave Request.Offset zero — it is denju's. A plain Source, httpsource included, is unaffected: it always sees zero and never leaves a partial behind. A download through a plain Source deletes every partial first, including one an earlier resumable attempt at the same request left.

Use one Updater per binary, across processes as well as within one. Two Updaters downloading beside the same binary delete each other's partials as belonging to a different request, and nothing enforces the rule.

Tell failures apart with errors.Is on Result.Cause. Result.Error carries the same text as before.

Kind Meaning Retry
ErrDownloadFailed the transfer failed or timed out worth retrying; a ResumableSource resumes
ErrResumeRejected the source cannot continue from the offset the next attempt starts from zero
ErrResumedChecksumMismatch a download that resumed failed the digest: the kept bytes may be damaged, or the source ignored the offset retry once from zero; a second mismatch is ErrChecksumMismatch
ErrChecksumMismatch a download from byte zero failed the digest: the artifact is wrong permanent

The two mismatch kinds are distinct: errors.Is(err, ErrChecksumMismatch) is false for a resumed mismatch. Every other failure, including a local one such as a full disk, carries no kind.

What denju verifies

denju checks that the SHA-256 you pass in Request.SHA256 matches the bytes it writes, and that TargetOS/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 your job, and it is the part that matters. Carry the digest over a channel you already trust — a signed manifest, an authenticated RPC — and denju will make sure the bytes on disk are the bytes you were promised. See SECURITY.md.

The namespace is permanent

Config.Namespace determines the name of every file denju writes beside your binary and every environment variable it sets:

journal <binary>.<ns>-update.state.json
restart journal <binary>.<ns>-restart.state.json
rollback copy <binary>.old
helper copy (Windows) <binary>.<ns>-updater.exe
discarded binary (Windows) <binary>.<ns>-update.discard
helper log (Windows) <binary>.<ns>-update.log
outcome record <binary>.<ns>-record.json, or Config.RecordPath
partial download <binary>.<ns>-download-<key>.partial (a ResumableSource only)
retained previous binary <binary>.<ns>-previous (RetainPrevious only)
its record <binary>.<ns>-previous.json
environment <NS>_UPDATE_MODE, <NS>_UPDATE_STATE, <NS>_SELFTEST

These names are an on-disk contract between consecutive versions of your program: the version being replaced writes the journal that the version replacing it has to read. Changing the namespace of a deployed program 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.

How it behaves

On Unix, the swap hardlinks the old binary to <binary>.old, then replaces the binary with a single atomic rename — the binary is never absent, so a power cut cannot strand a host with nothing to run. Then syscall.Exec replaces the process image inside the same PID. systemd's Restart=, launchd's KeepAlive and container restart policies are never consulted, because from the supervisor's point of view nothing happened.

On Windows, which can neither overwrite a running .exe nor exec, denju copies itself aside and spawns that copy fully detached. The copy waits for the original to exit, performs the same swap, and starts the program again — through the service control manager when it runs as a service, so the successor is the service. The original does not exit until the helper has proved it started.

When something goes wrong, startup repair reads the journal and reconciles the disk deterministically: an interrupted swap is finished or undone, a new version that keeps crashing before it can attest is rolled back after CrashTolerance starts, and an orphaned journal from a crash long past is collected.

Cooldown (off by default) refuses updates for a while. Config.CooldownScope decides which: CooldownAnyUpdate (the default, and the only behaviour before v0.4.0) refuses every update after one completes; CooldownRolledBackVersion refuses only the version most recently rolled back and admits every other target at once, so a corrective update or a move back to the retained binary is not held unless that version is itself the one just rolled back. Turn it on if your control plane can re-issue an update to a program that just rolled it back — without it that is an unbounded loop that re-downloads the binary every cycle. InCooldownFor answers for a particular target.

Keeping the previous binary

With Config.RetainPrevious, a commit keeps the binary the update replaced as <binary>.<ns>-previous, described by <binary>.<ns>-previous.json, instead of deleting it. One generation is kept; the next commit replaces it, and a rollback leaves it alone.

if path, sum, _, ok := u.PreviousBinary(); ok && strings.EqualFold(sum, req.SHA256) {
    u.Update(ctx, req, denju.FileSource(path)) // no network at all
}

FileSource is verified against Request.SHA256 like any source. Budget the disk: one extra binary for good. The rollback copy costs nothing extra on Unix — it is a hardlink to the running binary — 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 run the retained file in place.

If keeping it fails at the commit, the commit still stands: the replaced binary stays as <binary>.old, and CleanupReported and every later Repair try again. Once it succeeds, Repair removes the leftover journal itself.

The scoped cooldown and the retained binary each depend on particular images having v0.4.0:

  • The scoped hold needs both images — the one rolled back from and the one rolled back to — on denju v0.4.0 or later with CooldownRolledBackVersion.
    • The image rolled back from (the update's target) writes the hold. An attestation failure and a crash loop both record the rollback, with the hold, before restoring the previous binary.
    • The image rolled back to reads and enforces the hold when the next command arrives, and carries it forward on every later write. An image on an older denju enforces nothing, and drops the hold on its next record write (MarkReported included, through the 0.3.0 Outcome shape, which has no field to carry it).
    • The image rolled back to records the rollback itself only when nothing has recorded it (the new version never started, or the record write failed), and it never adds a hold to a rollback that is already recorded.
    • So when a program on v0.4.0 is moved back to a build on denju v0.3.0, and that build fails and is rolled back, the v0.4.0 program is not held: the older build recorded the rollback without a hold. It will accept the same move again at once. Denju does not stop that loop; the control plane has to, for example by pausing the rollout on the first failure.
  • The retained binary is created by the image that commits — the update's target. It needs denju v0.4.0 or later with RetainPrevious; a commit by an image on an older denju deletes the replaced binary instead of keeping it.

Running the tests

make test        # go test ./... -count=1
make test-race   # with the race detector
make lint        # go vet

The suite includes end-to-end tests that compile real throwaway binaries and update one into another, so the selftest child protocol and the swap are exercised for real rather than mocked.

License

Apache-2.0. See LICENSE.

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

Constants

This section is empty.

Variables

View Source
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

func ResolveBinary() (string, error)

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 Level

type Level int

Level is the severity of a line denju emits.

const (
	LevelDebug Level = iota
	LevelInfo
	LevelWarn
	LevelError
)

Severity levels, ordered.

func (Level) String

func (l Level) String() string

String returns the lowercase level name.

type Logger

type Logger func(level Level, msg string, attrs ...any)

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.

func SlogLogger

func SlogLogger(l *slog.Logger) Logger

SlogLogger adapts a *slog.Logger to Logger.

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

func New(cfg Config) (*Updater, error)

New validates cfg and prepares an Updater. It performs no I/O beyond resolving the running executable's path.

func (*Updater) Attest

func (u *Updater) Attest(ctx context.Context, d time.Duration)

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

func (u *Updater) BinaryPath() string

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

func (u *Updater) Commit() error

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

func (u *Updater) InCooldown(now time.Time) (bool, time.Duration, error)

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

func (u *Updater) InCooldownFor(targetVersion string, now time.Time) (bool, time.Duration, error)

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

func (u *Updater) MarkReported() error

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

func (u *Updater) PendingAttestation() (Pending, bool)

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

func (u *Updater) PendingOutcome() (*Outcome, error)

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

func (u *Updater) PreviousBinary() (path, sha256, version string, ok bool)

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

func (u *Updater) Repair() error

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

func (u *Updater) Restart() error

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

func (u *Updater) Rollback(cause string) error

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

func (u *Updater) RunProcessRole() (exitCode int, isRole bool)

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

func (u *Updater) Update(ctx context.Context, req Request, src Source) Result

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.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL