denju

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Jul 26, 2026 License: Apache-2.0 Imports: 17 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.

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.

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))

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
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 a new update for a while after one completes. Turn it on if your control plane can command a downgrade, or can re-issue an update to a program that just rolled one back — without it, those are unbounded loops that re-download the binary every cycle.

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.

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.

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

This section is empty.

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.
	Cooldown time.Duration

	// 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
}

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 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"`
}

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
}

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
}

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 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. An implementation only has to produce bytes or an error.
	//
	// 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.

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 an update completed recently enough that another should be refused, and how long remains. 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) 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) 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 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.

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