Documentation
¶
Overview ¶
Package distkit owns binary distribution and self-update for the kfet family of tools: fir, harb, mintick, and the ACP relays.
It replaces four hand-written self-update implementations, each of which was missing a different piece, and it carries the union of what they got right:
- With a token every byte moves through the GitHub REST API, asset payloads included (`Accept: application/octet-stream`). That is the only shape that works against a repo that is still private. Without one — the ordinary public case — the API is skipped entirely: the unauthenticated limit is 60 requests/hour per IP ADDRESS, so a NAT'd fleet spends it between its own hosts, and an update must not fail for that. "latest" then comes from the /releases/latest redirect and assets from the download host, at zero API cost.
- sha256 of the downloaded asset is checked against the release's `checksums.txt` before anything is moved into place.
- The replacement is an ETXTBSY-safe atomic swap: stage in a temp dir NEXT TO the running binary (same directory → same filesystem) and os.Rename over it. A running executable cannot be written or truncated in place, but renaming a sibling over it replaces the directory entry while the live process keeps its old inode mapped.
- A Homebrew-managed install is detected and UPGRADED THROUGH BREW rather than merely refused: self-updating a keg would be silently reverted by the next `brew upgrade`.
- An install this process does not own — a package-manager prefix, a directory belonging to another user — is refused up front, with the command to use instead, rather than failing three network round-trips later with "permission denied".
The package depends on nothing outside the standard library, and on nothing from ACP, any relay, or fir: every tool in the family must be able to import it.
The usual consumer wires it up in one call:
distkit.Main(distkit.Config{
Repo: "kfet/zulip-acp",
Binary: "zulip-acp",
AssetStem: "zulip-acp",
Version: version,
RestartHint: "systemctl --user reload zulip-acp",
})
Check, Download, Apply, DetectBrewInstall and UpgradeViaBrew are exported separately for callers that want the pieces without the CLI.
Index ¶
- Constants
- func Apply(staged, target string) error
- func CheckWritable(path, binary string) error
- func DiscoverToken() string
- func Download(ctx context.Context, cfg Config, rel *Release, dir string) (string, error)
- func EnsureV(s string) string
- func IsDevBuild(v string) bool
- func LookupChecksum(manifest []byte, asset string) (string, error)
- func Main(cfg Config) int
- func StagingDir(target, binary string) (string, error)
- func UpgradeViaBrew(ctx context.Context, inst *BrewInstall, binary string, ...) error
- type Asset
- type BrewInstall
- type Config
- type ErrManaged
- type Release
- type Result
- type Status
Constants ¶
const DefaultAPIBase = "https://api.github.com"
DefaultAPIBase is the GitHub REST API root.
const DefaultAssetTemplate = "{stem}-{os}-{arch}"
DefaultAssetTemplate names a raw-binary release asset, which is what goreleaser's `formats: [binary]` produces. See Config.AssetTemplate.
const DefaultChecksums = "checksums.txt"
DefaultChecksums is the release asset listing sha256 sums, in the `sha256sum` output format that goreleaser writes.
const DefaultDownloadBase = "https://github.com"
DefaultDownloadBase is the host serving release downloads and the /releases/latest redirect. Unlike the API it has no per-IP request budget, which is what makes the anonymous path in github.go possible.
const DefaultStallTimeout = 2 * time.Minute
DefaultStallTimeout is how long a download may make no progress at all before it is abandoned. See Config.StallTimeout.
const ExitUpdateAvailable = 3
Main implements the `<tool> update` subcommand and returns the process exit code. It is the whole integration surface for a consumer:
case "update":
os.Exit(distkit.Main(distkit.Config{
Repo: "kfet/zulip-acp",
Binary: "zulip-acp",
Version: version,
RestartHint: "systemctl --user reload zulip-acp",
}))
Flags parsed from Config.Args (default os.Args[2:], i.e. everything after the subcommand word):
-check report whether an update is available; install nothing -version <tag> install a specific release instead of the latest -repo <owner/name> override the source repository -restart-cmd <sh> run this after a successful update
Exit codes: 0 success (including "already up to date"), 1 the update failed or was refused, 2 the flags did not parse, and ExitUpdateAvailable (3) for a -check run that found a newer release. ExitUpdateAvailable is the exit code of a `-check` run that found a release different from the one running. It is distinct from 0 ("already up to date") so a scheduled check can act on the result without parsing stdout.
Variables ¶
This section is empty.
Functions ¶
func Apply ¶
Apply replaces target with the file at staged.
This is the ETXTBSY-safe swap. A running executable cannot be opened for writing or truncated — the kernel returns ETXTBSY — but its *directory entry* can be replaced. os.Rename does exactly that, atomically, and the process that is still running keeps the old inode mapped until it re-execs. That is why staged must be a sibling of target: a rename across filesystems degrades to copy-then-unlink, which is neither atomic nor permitted here.
func CheckWritable ¶
CheckWritable reports whether the resolved binary at path may be replaced by this process: not under a package-manager prefix, and in a directory owned by the effective user.
The directory ownership matters rather than the file's: the swap creates a temp file in the install directory and renames it over the binary, so a directory belonging to someone else fails no matter what the file's mode says. Checking it here turns a confusing "permission denied" after three network round-trips into an immediate, explainable refusal.
func DiscoverToken ¶
func DiscoverToken() string
DiscoverToken finds a GitHub token the way the surrounding tooling does: the standard environment variables first, then a logged-in `gh`. A fleet host usually has gh configured but rarely exports a token. Returns "" when there is none, which is fine against a public repo.
func Download ¶
Download fetches the release asset for the current platform into dir, verifies its sha256 against the release's checksum manifest, unpacks it if it is an archive, and returns the path of the executable ready to be installed.
dir must be on the same filesystem as the binary that will be replaced for Apply to be atomic; StagingDir creates such a directory. The caller owns dir and is responsible for removing it.
Verification is not optional in any sane configuration: the bytes are only trusted once the manifest agrees, and the manifest is fetched over the same authenticated API call chain as the asset itself.
func EnsureV ¶
EnsureV prepends "v" to a version string that lacks it, so that a tag written "0.4.1" and one written "v0.4.1" compare equal.
func IsDevBuild ¶
IsDevBuild reports whether v is a placeholder or a working-tree build rather than a released tag.
Self-updating one would rename a release binary over a developer's own build — it can never equal a tag, so every check reports "available" — which is a surprising thing to do to someone's working tree.
Semver build metadata (everything from the first "+") is stripped before the suffix test, because it says nothing about whether this is a release: a Makefile that stamps "0.6.1-dev+abc1234.dirty" is describing the same working-tree build as one that stamps a bare "0.6.1-dev", and the guard must not fire for one and not the other merely because the first appends a commit sha. Note that stripping also disarms the "+dev" suffix, so that case is checked before.
func LookupChecksum ¶
LookupChecksum returns the hex sha256 recorded for asset in a manifest in `sha256sum` format ("<hex> <name>"), as written by goreleaser.
func StagingDir ¶
StagingDir creates a temporary directory in the same directory as target, which is what makes the subsequent Apply an atomic same-filesystem rename. The caller must os.RemoveAll it.
func UpgradeViaBrew ¶
func UpgradeViaBrew(ctx context.Context, inst *BrewInstall, binary string, stdout, stderr io.Writer) error
UpgradeViaBrew runs `brew update` followed by `brew upgrade <formula>`, streaming both commands' output to the supplied writers so a multi-minute upgrade is not swallowed into silence.
No timeout is imposed: a brew upgrade can legitimately take many minutes (it may compile). Cancellation is the caller's ctx.
Types ¶
type Asset ¶
type Asset struct {
// Name is the file name, e.g. "zulip-acp-linux-amd64".
Name string `json:"name"`
// URL is the asset's API URL. Fetching it with
// Accept: application/octet-stream returns the bytes; without that
// header it returns the asset's JSON metadata.
URL string `json:"url"`
}
Asset is one file attached to a release.
type BrewInstall ¶
type BrewInstall struct {
// ExePath is the running executable with symlinks resolved — the real
// file inside the Cellar.
ExePath string
// Prefix is the Homebrew prefix the Cellar belongs to, e.g. "/opt/homebrew".
Prefix string
// Formula is the name to hand to `brew upgrade`. Fully qualified when we
// could resolve it (e.g. "kfet/ai/fir"), otherwise the bare keg name.
Formula string
// BrewPath is the resolved `brew` executable, empty when brew is not on
// PATH. An install with a non-empty Prefix and an empty BrewPath is the
// one case we must refuse to self-update.
BrewPath string
}
BrewInstall describes a detected Homebrew-managed installation.
func DetectBrewInstall ¶
func DetectBrewInstall(ctx context.Context) (*BrewInstall, error)
DetectBrewInstall reports whether the running executable is managed by Homebrew. It returns (nil, nil) when the install is not brew-managed — including every case where we are merely unsure, since a false positive breaks `<tool> update` on non-brew hosts while a false negative only leaves us on the pre-existing self-update path.
type Config ¶
type Config struct {
// Repo is the GitHub "owner/name" releases are taken from, e.g.
// "kfet/zulip-acp". Required.
Repo string
// Binary is the installed file name, e.g. "zulip-acp". It is also the
// name searched for inside an archive asset. Required.
Binary string
// AssetStem is the "{stem}" of the release asset name. Defaults to
// Binary, which is right for every consumer today.
AssetStem string
// Version is the compiled-in version of the running binary, with or
// without a leading "v". Required.
Version string
// RestartHint is the command that makes a swapped binary take effect,
// e.g. "systemctl --user reload zulip-acp". A replaced binary is inert
// until the process re-execs — the running one still has the old inode
// mapped — so this is printed after every successful update. Optional:
// a one-shot CLI has nothing to recycle.
RestartHint string
// AssetTemplate names the release asset. Placeholders:
//
// {stem} AssetStem
// {binary} Binary
// {os} runtime.GOOS
// {arch} release arch suffix (see ArmSuffix)
// {version} resolved tag, with leading v
// {version_no_v} resolved tag, without leading v
//
// Defaults to DefaultAssetTemplate ("{stem}-{os}-{arch}"), a raw
// binary. When the rendered name ends in .tar.gz, .tgz or .zip the
// asset is treated as an archive and Binary is extracted from it —
// harb publishes "{stem}-{version_no_v}-{os}-{arch}.tar.gz".
AssetTemplate string
// ChecksumsAsset is the name of the checksum manifest in the release.
// Defaults to DefaultChecksums.
ChecksumsAsset string
// SkipChecksums installs the asset without verifying it. No consumer
// should set this: the bytes are only trustworthy once the manifest
// agrees. It exists for a project whose release process genuinely
// publishes no manifest.
SkipChecksums bool
// ArmSuffix is the arch token used in asset names for 32-bit ARM,
// where GOARCH is the uninformative "arm". The family builds at
// GOARM=6 and publishes "armv6"; that is the default. A project whose
// assets say plain "arm" sets it to "arm".
ArmSuffix string
// DisableBrew turns off Homebrew detection. With it set, a keg install
// is refused by the managed-prefix guard instead of being upgraded
// through `brew upgrade`. Only useful for a binary that is never
// distributed as a formula.
DisableBrew bool
// Token is a GitHub token. Empty means discover one from the
// environment (GITHUB_TOKEN, GH_TOKEN) and then from a logged-in `gh`.
Token string
// Anonymous forbids authentication entirely: no discovery, no `gh`
// exec, and no Authorization header even if Token or GITHUB_TOKEN is
// set. Resolution then always takes the /releases/latest redirect on
// the download host, and only falls back to the API unauthenticated.
//
// It exists for an EMBEDDED caller — a background version check inside
// another program — where the two costs of discovery are unacceptable:
// exec'ing `gh auth token` on a startup path (up to 10s, outside the
// caller's context), and a resolution path that silently differs
// between a host with `gh` logged in and one without. A private repo
// cannot be resolved this way; that is the trade.
Anonymous bool
// APIBase is the GitHub API root; empty means DefaultAPIBase. Tests
// point it at an httptest server.
APIBase string
// DownloadBase is the host release assets are fetched from when no
// token is in play, and the host whose /releases/latest redirect
// resolves "latest" without spending API quota. Empty means
// DefaultDownloadBase. Tests point it at an httptest server; a GitHub
// Enterprise install points it at its own web host.
DownloadBase string
// HTTPClient overrides the default client. The default deliberately
// has NO total-request deadline: a release binary is ~10 MB and a Pi
// on a slow link legitimately takes minutes. It bounds the part that
// can actually hang — a server that accepts the connection and then
// never answers — with ResponseHeaderTimeout.
HTTPClient *http.Client
// StallTimeout bounds how long a download may make NO progress before it
// is abandoned. It is not a total deadline — a slow link is fine, a
// stalled one is not — and it is what stops a dropped connection
// mid-body from wedging a systemd timer forever. Defaults to
// DefaultStallTimeout; a negative value disables the check.
StallTimeout time.Duration
// Stdout and Stderr default to the process's. Progress and results go
// to Stdout, diagnostics to Stderr.
Stdout io.Writer
Stderr io.Writer
// ExecPath overrides os.Executable. Tests use it; production does not.
ExecPath func() (string, error)
// Args are the CLI arguments AFTER the "update" subcommand word. nil
// means take them from os.Args[2:], which is what a consumer that
// dispatches `<tool> update ...` straight into Main wants. A consumer
// with global flags BEFORE the subcommand (`tool -config x update -check`)
// must set this explicitly — os.Args[2:] would be wrong there.
Args []string
// TargetVersion pins the release to install, e.g. "v0.19.0". Empty
// means latest. The -version flag sets it when going through Main.
TargetVersion string
// CheckOnly resolves and reports without downloading or replacing
// anything. The -check flag sets it when going through Main.
CheckOnly bool
// contains filtered or unexported fields
}
Config describes one consumer's distribution setup. Repo, Binary and Version are required; everything else has a working default.
type ErrManaged ¶
type ErrManaged struct {
// Path is the resolved binary.
Path string
// Reason explains what makes it unsafe.
Reason string
// Hint is the command to use instead, if there is one.
Hint string
}
ErrManaged is returned when the install is owned by something other than this process. Callers can errors.As it to distinguish "you must not" from "it went wrong".
func (*ErrManaged) Error ¶
func (e *ErrManaged) Error() string
type Release ¶
type Release struct {
TagName string `json:"tag_name"`
// Assets is the release's asset list as the API reports it. It is EMPTY
// for a release resolved without a token, where no such list is ever
// fetched — use AssetURL, which covers both cases, rather than ranging
// over this.
Assets []Asset `json:"assets"`
// contains filtered or unexported fields
}
Release is the subset of the GitHub release JSON this package uses.
Asset.URL is the *API* URL (…/releases/assets/123), not the browser_download_url: the API URL serves the bytes to a bearer token, which is the only thing that works while the repo is private.
func FetchRelease ¶
FetchRelease resolves a release: the latest one when tag is empty, otherwise that exact tag.
With a token this goes through the GitHub REST API, which is the only thing that works against a private repo. WITHOUT one it deliberately does not: the unauthenticated API limit is 60 requests/hour PER IP ADDRESS, so a fleet behind one NAT, a CI runner, or a shared office link can arrive with it already spent, and `update` must not fail for that. Anonymously a pinned tag needs no lookup at all, and "latest" is read from the /releases/latest redirect on the download host, which costs no quota. The API remains the fallback for when that redirect yields nothing usable.
Anonymously a pinned tag is therefore taken at its word: it is not checked to exist, so a Check of one that does not will report it as available and only the download will fail (with an error that says so).
func (*Release) AssetURL ¶
AssetURL returns the URL of the named asset: its API URL for a release resolved through the API, or its download URL for one resolved anonymously, where no asset list was ever fetched. In the anonymous case the name is not validated here — a file that is not in the release surfaces as a 404 from the download itself.
type Result ¶
type Result struct {
// Current is the version that was running, with a leading v.
Current string
// Target is the version resolved for installation, with a leading v.
// Empty when ViaBrew is set (brew resolves the version itself) and
// when the run failed before the release was resolved.
Target string
// Updated is true iff the binary was actually replaced (or upgraded
// through brew).
Updated bool
// ExecPath is the resolved path of the binary, symlinks followed.
ExecPath string
// ViaBrew is true when the work was done by `brew upgrade` rather than
// by downloading a release asset.
ViaBrew bool
// Available is set by a CheckOnly run that found a different release.
// It is the machine-readable form of what -check prints.
Available bool
}
Result reports what an update run did, so the caller can decide follow-up actions such as recycling a service.
type Status ¶
type Status struct {
// Current is the running version, with a leading v.
Current string
// Target is the resolved release tag, with a leading v.
Target string
// Available reports whether Target differs from Current. With
// TargetVersion pinned this may be a downgrade — the field says
// "different", not "newer", because installing a pin is legitimate.
Available bool
// Release is the resolved release, carrying the asset API URLs.
Release *Release
}
Status is what Check resolved, without touching the installed binary.
func Check ¶
Check resolves the release that `update` would install and compares it with the running version. It touches nothing on disk, so it is safe to call from a status command or a background nag. Without a token it also spends no GitHub API quota — see FetchRelease.
The comparison is exact-match on the tag, not "is newer": with Config.TargetVersion pinned the target may legitimately be older than what is installed, and calling a deliberate downgrade "no update available" would refuse to do a thing the operator explicitly asked for.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
distkit-installsh
command
Command distkit-installsh generates a repo's root install.sh from the canonical distkit template, or checks the checked-in copy for drift.
|
Command distkit-installsh generates a repo's root install.sh from the canonical distkit template, or checks the checked-in copy for drift. |
|
Package installsh generates a consumer repo's root install.sh from one canonical template.
|
Package installsh generates a consumer repo's root install.sh from one canonical template. |