Documentation
¶
Overview ¶
Package pinstall installs, activates and maintains a digest-pinned upstream release.
The unit of installation is <Root>/<Name>-versions/<version>/. It is populated only from a digest-verified archive, published by a single same-filesystem rename, and marked by a ".complete" sentinel written LAST — so an interrupted install is detectable by absence of the sentinel and never becomes a selection candidate. Nothing at all is placed under Root before the archive digest matches the pin.
Every start re-probes the artifact it is about to activate, re-asserts the caller's assertions against it, retains N predecessors and reports a readiness verdict. Nothing here exits the process, reads the environment, or performs work at import time: a failed install is returned or logged so the caller's own surfaces stay alive and the installation can be repaired in place.
A caller builds a Release once (everything true of the package, independent of any deployment), then one Config per deployment (the pin, the digests, the root and the local policy), then calls New:
mgr, err := pinstall.New(&pinstall.Config{
Release: myprofile.Release(),
Version: "1.4.2",
Digests: map[string]string{"amd64": amd64SHA256, "arm64": arm64SHA256},
Root: "/var/lib/example/tools",
})
if err != nil {
return err
}
if err := mgr.EnsureWithRetry(ctx); err != nil {
log.Printf("install failed, serving degraded: %v", err)
}
if ready, why := mgr.Ready(); !ready {
return fmt.Errorf("not ready: %s", why)
}
cmd := exec.CommandContext(ctx, mgr.Path(), "--help")
Durability, version selection, retry, retention and the readiness verdict are the library's and are deliberately not configurable. What varies between packages is data: the URL shape, the architecture tokens, the in-archive installer, the probe argv, the assertions.
Linux only. The publish protocol relies on same-filesystem rename and fsync of a directory, and the confined deletes use os.Root.
Index ¶
- Variables
- func LastFieldOfFirstLine(out string) string
- func UnpackZip(ctx context.Context, archive, dir string) error
- type ArchiveInstaller
- type Assertion
- type Config
- type Manager
- func (m *Manager) Active() (State, bool)
- func (m *Manager) Ensure(ctx context.Context) error
- func (m *Manager) EnsureWithRetry(ctx context.Context) error
- func (m *Manager) Path() string
- func (m *Manager) PathEntry() string
- func (m *Manager) Ready() (ready bool, why Reason)
- func (m *Manager) Rescan(ctx context.Context) (bool, error)
- type Purge
- type Reason
- type Release
- type State
- type Unpacker
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrDigestMismatch reports that the downloaded archive is not the artifact // the pinned digest names. Nothing is placed under Root when it is returned. ErrDigestMismatch = errors.New("archive SHA-256 mismatch") // ErrUnsupportedArch reports an architecture the release publishes no // archive for, or one with no pinned digest. ErrUnsupportedArch = errors.New("unsupported architecture") // ErrNoVersion reports that no complete version is installed and none could // be installed, so there is nothing to activate. ErrNoVersion = errors.New("no complete version is installed") // ErrVersionMismatch reports that a candidate artifact answered the version // probe with something other than the version its directory and sentinel // claim. ErrVersionMismatch = errors.New("binary reported a version its install directory does not claim") )
Errors callers can classify. Everything else is wrapped with context.
Functions ¶
func LastFieldOfFirstLine ¶
LastFieldOfFirstLine is the default Release.ParseVersion: the last whitespace-separated field of the first line, and "" when there is none. It covers the common `<name> <version>` and bare `<version>` shapes and never panics on arbitrary input.
A release whose probe prints something else — JSON, a multi-column banner — sets its own parser, because probe output has no universal shape.
Example ¶
ExampleLastFieldOfFirstLine shows the default version parser, which covers the two common probe shapes and returns "" when there is nothing to take.
package main
import (
"fmt"
"github.com/cplieger/pinstall"
)
func main() {
fmt.Printf("%q\n", pinstall.LastFieldOfFirstLine("widget 1.4.2\n"))
fmt.Printf("%q\n", pinstall.LastFieldOfFirstLine("1.4.2\n"))
fmt.Printf("%q\n", pinstall.LastFieldOfFirstLine("widget 1.4.2\nbuilt 2026-01-01\n"))
fmt.Printf("%q\n", pinstall.LastFieldOfFirstLine("\n"))
}
Output: "1.4.2" "1.4.2" "1.4.2" ""
func UnpackZip ¶
UnpackZip is the shipped Unpacker: archive/zip with traversal, entry-count and total-size guards. It is used whenever Release.Unpack is nil.
Executable bits survive (an in-archive installer has to run) but nothing wider than owner-write does, because the extracted tree lands on a persistent volume.
Types ¶
type ArchiveInstaller ¶
type ArchiveInstaller struct {
// Path is the script's path relative to the extraction directory
// (e.g. "pkg/install.sh").
Path string
// HomeEnv is the environment variable pointed at the private staging home.
// Empty means HOME.
HomeEnv string
// Args is the argv passed to the script (e.g. {"--no-confirm"}).
Args []string
// Timeout bounds the run. Zero uses two minutes.
Timeout time.Duration
}
ArchiveInstaller describes an installer script shipped inside the archive. Its exit code is deliberately not fatal: an upstream installer commonly touches shell profiles and other surfaces that legitimately fail in a minimal container, so what decides is whether the artifacts it produced pass the staged gates.
type Assertion ¶
type Assertion struct {
// Name identifies the assertion in logs and is the key
// [Release.Mandatory] overrides on. It is not passed to the artifact.
Name string
// Args is the full argv after the artifact path
// (e.g. {"settings", "app.disableAutoupdates", "true"}).
Args []string
// Required marks an assertion whose failure is integrity-relevant.
Required bool
}
Assertion is one bounded command run against the installed artifact on every start, and against the STAGED artifact before publication when Required. Args is the full argv after the artifact path, so the library needs no knowledge of the package's configuration grammar.
A Required failure withholds readiness (and, before publication, refuses to publish). Anything else only warns.
type Config ¶
type Config struct {
// Digests maps a GOARCH to the lowercase hex SHA-256 of that
// architecture's archive. The resolved architecture must have an entry.
Digests map[string]string
// Purge sweeps a previous installer's layout once. Nil skips it.
Purge *Purge
// Assert are the assertions re-asserted against the active artifact on
// every start. [Release.Mandatory] is merged in, so a required assertion
// cannot be dropped here.
Assert []Assertion
// Require names artifacts a version directory MUST hold to count as
// complete. The release's primary artifact is always included.
Require []string
// Release is the package profile.
Release Release
// Version is the pin, validated against a path- and URL-safe character set
// because it is interpolated into both a URL and a filesystem path.
Version string
// Root is the persistent installation root: the only tree this package
// reads, writes or deletes.
Root string
// GOARCH selects the architecture. Empty resolves it from runtime.GOARCH.
GOARCH string
// URLTemplate overrides [Release.URLTemplate], for a mirror or a test
// server. Empty uses the release's own template.
URLTemplate string
// LinkDir is a directory under Root holding a NON-AUTHORITATIVE
// convenience symlink at the active artifact, for an operator shelling
// into the environment. Empty publishes none. Nothing in this package ever
// reads the link.
LinkDir string
// Optional names artifacts installed when the archive provides them, and
// only warned about when it does not. It closes the pointer-bearing fields
// rather than sitting beside Require only because a trailing slice costs the
// garbage collector less to scan.
Optional []string
// Retain is how many predecessors to keep besides the active version. Zero
// uses 1, which is what makes a bad activation recoverable without a
// rollback journal.
Retain int
// RetryBackoff is the first [Manager.EnsureWithRetry] backoff; it doubles
// per attempt up to a ten minute cap. Zero uses 30s.
RetryBackoff time.Duration
// MaxAttempts bounds [Manager.EnsureWithRetry]. Zero uses 4. The retries
// are bounded deliberately: an endless loop re-downloading a large archive
// is worse than a visible failure an operator can repair.
MaxAttempts int
// Untrusted records that Root was writable by others. A sentinel is
// trivially forgeable, unlike a digest, so when it is set no pre-existing
// version directory may be activated: only a version THIS process
// installed from a verified archive counts.
Untrusted bool
}
Config is one deployment of one Release: the pin, the digests, the root and the local policy. The zero value is not usable — call New.
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager installs, activates and maintains one pinned release. It is safe for concurrent use: Manager.Ensure and Manager.Rescan serialise against each other, while the readers never block behind an install.
func New ¶
New validates cfg and returns a manager.
It resolves the architecture from runtime.GOARCH when Config.GOARCH is empty, requires a well-formed digest for the resolved architecture, merges Release.Mandatory into Config.Assert, and refuses a profile that declares no mandatory assertions. The caller's Config is not modified, and every fact this manager depends on is copied, so a later mutation of the caller's maps or slices cannot change its behaviour.
Example ¶
ExampleNew builds a manager for one deployment of the widget release. The pin and the digests come from wherever the consumer keeps them; nothing is downloaded until Ensure runs.
package main
import (
"fmt"
"strings"
"time"
"github.com/cplieger/pinstall"
)
// widgetRelease is the profile a consumer of a fictional "widget" release writes
// once. Everything in it is true of the package regardless of where it is deployed.
func widgetRelease() pinstall.Release {
return pinstall.Release{
Name: "widget",
Binary: "widget-cli",
URLTemplate: "https://widgets.example/dl/{arch}/widget_{version}.zip",
ArchTokens: map[string]string{"amd64": "linux-64", "arm64": "linux-arm"},
Installer: &pinstall.ArchiveInstaller{
Path: "widget/install.sh",
Args: []string{"--no-confirm"},
Timeout: 2 * time.Minute,
},
ArtifactDir: ".local/bin",
ProbeArgs: []string{"--version"},
Mandatory: []pinstall.Assertion{
{Name: "autoupdate", Args: []string{"config", "set", "autoupdate", "off"}},
},
}
}
func main() {
mgr, err := pinstall.New(&pinstall.Config{
Release: widgetRelease(),
Version: "1.4.2",
Digests: map[string]string{
"amd64": strings.Repeat("a", 64),
"arm64": strings.Repeat("b", 64),
},
Root: "/var/lib/example/tools",
GOARCH: "amd64",
LinkDir: "bin",
Require: []string{"widget-helper"},
Assert: []pinstall.Assertion{
{Name: "telemetry", Args: []string{"config", "set", "telemetry", "off"}},
},
Retain: 2,
})
if err != nil {
fmt.Println("config error:", err)
return
}
// Before the first Ensure nothing is active, and the reason says why.
ready, why := mgr.Ready()
fmt.Println("ready:", ready, "reason:", why)
fmt.Printf("path: %q\n", mgr.Path())
}
Output: ready: false reason: installing path: ""
Example (MissingMandatory) ¶
ExampleNew_missingMandatory shows the one construction error a profile author is most likely to hit: a release with no mandatory assertion is refused, so a guarantee cannot be lost by omitting it.
package main
import (
"fmt"
"strings"
"time"
"github.com/cplieger/pinstall"
)
// widgetRelease is the profile a consumer of a fictional "widget" release writes
// once. Everything in it is true of the package regardless of where it is deployed.
func widgetRelease() pinstall.Release {
return pinstall.Release{
Name: "widget",
Binary: "widget-cli",
URLTemplate: "https://widgets.example/dl/{arch}/widget_{version}.zip",
ArchTokens: map[string]string{"amd64": "linux-64", "arm64": "linux-arm"},
Installer: &pinstall.ArchiveInstaller{
Path: "widget/install.sh",
Args: []string{"--no-confirm"},
Timeout: 2 * time.Minute,
},
ArtifactDir: ".local/bin",
ProbeArgs: []string{"--version"},
Mandatory: []pinstall.Assertion{
{Name: "autoupdate", Args: []string{"config", "set", "autoupdate", "off"}},
},
}
}
func main() {
release := widgetRelease()
release.Mandatory = nil
_, err := pinstall.New(&pinstall.Config{
Release: release,
Version: "1.4.2",
Digests: map[string]string{"amd64": strings.Repeat("a", 64)},
Root: "/var/lib/example/tools",
GOARCH: "amd64",
})
fmt.Println(strings.SplitN(err.Error(), ";", 2)[0])
}
Output: pinstall: Release.Mandatory is empty
func (*Manager) Active ¶
Active returns the diagnostic state record and whether a version is active.
func (*Manager) Ensure ¶
Ensure brings the pinned version online and is idempotent: on a start that already has the pin complete and activatable it downloads nothing and still re-asserts every assertion.
The order is the design. The one-shot purge runs first (it deletes a previous layout outright, so nothing downstream can be fooled by it), then partial directories go (a partial is never a selection candidate), then selection probes each candidate's own version before accepting it, then — only when the pin is not already activatable — the install runs, then the assertions are re-asserted against whatever was selected, then the convenience link is republished, and only then may pruning run.
func (*Manager) EnsureWithRetry ¶
EnsureWithRetry drives Manager.Ensure with bounded exponential backoff. A single one-shot attempt would leave a failed first start withholding readiness forever with no further effort. It never exits the process, and it returns the last error after the attempts are exhausted.
func (*Manager) Path ¶
Path returns the absolute path of the active primary artifact, or "" when no version is active. This — never the convenience link — is what a consumer runs.
func (*Manager) PathEntry ¶
PathEntry returns the absolute directory to lead PATH with, or "" when no version is active. It holds only this release's own artifacts, so leading with it shadows nothing else.
func (*Manager) Ready ¶
Ready reports whether the installation may be used, and why not when it may not.
It is true only when a version is active AND this process asserted every required assertion against that exact artifact. The persisted State.AssertionsOK is never consulted: an assertion's effect lives in the package's own mutable configuration, so remembering that it once succeeded is stale evidence.
func (*Manager) Rescan ¶
Rescan re-derives the active version from what is on disk right now, without downloading anything, and re-asserts the assertions. It makes a repair performed in place — an operator restoring a version directory, or replacing a wedged artifact — observable without a fresh process. It returns whether a version is active afterwards.
type Purge ¶
type Purge struct {
// Artifacts are paths relative to Root, removed only when they are regular
// files.
Artifacts []string
// StagePrefix matches orphan staging trees directly under Root, removed
// only when they are directories. Empty matches nothing.
StagePrefix string
// Marker is a dot-file under Root recording that the sweep completed, so it
// runs once per volume rather than on every start. Empty reruns it every
// start.
Marker string
// Names are entries directly under LinkDir, removed only when they are
// regular files. A symlink at one of these paths belongs to another owner.
Names []string
}
Purge is the one-shot sweep of a layout a PREVIOUS installer left on this volume. It is injected rather than built in because the residue is a fact about one deployment's history, not about the package: two consumers of the same upstream release can have arrived from different installers.
Each target is removed only when what is on disk has the SHAPE the old installer left there, so another owner's entry at the same path is refused rather than deleted.
type Reason ¶
type Reason uint8
Reason explains a withheld readiness verdict. It is an enum rather than a string because the wording a consumer shows its own users is the consumer's: the library owns only the distinction between the states.
const ( // ReasonReady means readiness is not withheld. Its String is empty. ReasonReady Reason = iota // ReasonInstalling means the first attempt of this process is still in // flight and no version has ever been activated. ReasonInstalling // ReasonRetrying means an attempt failed and another one is scheduled. ReasonRetrying // scheduled. Only a repair plus [Manager.Rescan], or a fresh process, can // clear it. ReasonUnavailable // ReasonAssertion means a version is active but a Required assertion could // not be asserted against it, so a guarantee the install depends on does // not hold. ReasonAssertion )
The readiness reasons, in the order a start walks them.
func (Reason) String ¶
String returns a short, package-agnostic description of the reason, and "" for ReasonReady.
type Release ¶
type Release struct {
// Unpack extracts the verified archive. A nil Unpack uses [UnpackZip].
Unpack Unpacker
// Installer describes an installer script shipped INSIDE the archive. A nil
// Installer means the archive already holds the artifacts.
Installer *ArchiveInstaller
// ParseVersion extracts a version from the probe's output. A nil
// ParseVersion uses [LastFieldOfFirstLine].
ParseVersion func(out string) string
// ArchTokens maps a GOARCH to the token the publisher uses in its URLs
// (e.g. "amd64" -> "x86_64-linux"). An architecture absent from the map is
// [ErrUnsupportedArch].
ArchTokens map[string]string
// ProbeArgs is the argv that makes the primary artifact print its version
// (e.g. {"--version"}). Required: every start probes the artifact it is
// about to activate, so a release with no probe cannot be verified.
ProbeArgs []string
// Name is the package identity. It fixes the versions root
// (<Root>/<Name>-versions), the state file (<Root>/<Name>-state.json) and
// the sentinel's claim. It is NOT the artifact name — see Binary.
Name string
// Binary is the primary artifact: the file the version probe runs, the file
// the convenience link points at, and an always-present member of the
// required set. Empty defaults to Name, which covers the common case; a
// package named "aws-cli" that ships a binary called "aws" sets both.
Binary string
// URLTemplate is the archive URL, carrying the {version} and {arch}
// placeholders (e.g. "https://example.invalid/{version}/pkg-{arch}.zip").
// {arch} is substituted with the ArchTokens entry for the resolved GOARCH.
URLTemplate string
// ArtifactDir is where the artifacts land, relative to the installer's
// private home when Installer is set and to the extraction directory
// otherwise. Empty means the root of whichever of the two applies.
ArtifactDir string
// Notice is logged once per install attempt when non-empty: the licence
// acknowledgement a proprietary upstream requires. Profile-owned, because
// the library must not carry one vendor's licence text.
Notice string
// Mandatory are the assertions a deployment cannot configure away. Each one
// is forced Required and merged over any caller assertion with the same
// Name, so an integrity gate the package depends on (a self-update switch,
// a telemetry kill switch) cannot be lost by a deployment's omission.
//
// At least one is required: [New] refuses a profile that declares none,
// because "this package needs no post-install guarantee" and "the profile
// author forgot" are indistinguishable from the outside.
//
// It closes the field list rather than sitting beside ProbeArgs only because
// a trailing slice costs the garbage collector less to scan.
Mandatory []Assertion
}
Release is everything true of the PACKAGE, independent of any deployment: the profile a consumer builds once and reuses across every environment. It is data, not policy — durability, version selection, retry, retention and the readiness verdict belong to the library and are not configurable.
type State ¶
type State struct {
UpdatedAt time.Time `json:"updated_at"`
ActiveVersion string `json:"active_version"`
Dir string `json:"dir"`
Pinned string `json:"pinned"`
LastError string `json:"last_error,omitempty"`
AssertionsOK bool `json:"assertions_ok"`
}
State is the manager's persisted record, written to <Root>/<Name>-state.json. It is DIAGNOSTIC ONLY: every field is there for an operator reading the volume, and none of them is an input to Manager.Ready. AssertionsOK in particular records the last result as history — the assertion THIS process performed is the only thing that gates readiness, because an assertion's effect lives in the package's own mutable configuration rather than in the immutable version directory.
type Unpacker ¶
Unpacker extracts a verified archive into dir.
A custom implementation MUST refuse absolute and traversing entry names rather than sanitising them (a legitimate archive has no such entry, so rewriting one hides the archive that carries it), and MUST bound both the entry count and the total bytes written. The archive it is handed has already been proved against the pinned digest.